@lhi/n8m 0.2.4 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -15
- package/dist/agentic/graph.d.ts +16 -4
- package/dist/agentic/nodes/architect.d.ts +2 -2
- package/dist/agentic/nodes/architect.js +5 -1
- package/dist/agentic/nodes/engineer.d.ts +6 -0
- package/dist/agentic/nodes/engineer.js +39 -5
- package/dist/commands/create.js +38 -1
- package/dist/commands/deploy.d.ts +2 -1
- package/dist/commands/deploy.js +119 -19
- package/dist/commands/fixture.js +31 -8
- package/dist/commands/learn.d.ts +19 -0
- package/dist/commands/learn.js +277 -0
- package/dist/commands/modify.js +210 -68
- package/dist/commands/test.d.ts +4 -0
- package/dist/commands/test.js +118 -14
- package/dist/services/ai.service.d.ts +33 -0
- package/dist/services/ai.service.js +337 -2
- package/dist/services/node-definitions.service.d.ts +8 -0
- package/dist/services/node-definitions.service.js +45 -0
- package/dist/utils/fixtureManager.d.ts +10 -0
- package/dist/utils/fixtureManager.js +43 -4
- package/dist/utils/multilinePrompt.js +33 -47
- package/dist/utils/n8nClient.js +60 -11
- package/docs/DEVELOPER_GUIDE.md +598 -0
- package/docs/N8N_NODE_REFERENCE.md +369 -0
- package/docs/patterns/bigquery-via-http.md +110 -0
- package/oclif.manifest.json +82 -3
- package/package.json +3 -1
- package/dist/fixture-schema.json +0 -162
- package/dist/resources/node-definitions-fallback.json +0 -390
- package/dist/resources/node-test-hints.json +0 -188
- package/dist/resources/workflow-test-fixtures.json +0 -42
package/dist/commands/modify.js
CHANGED
|
@@ -72,20 +72,37 @@ export default class Modify extends Command {
|
|
|
72
72
|
this.log(theme.info('Searching for local and remote workflows...'));
|
|
73
73
|
const localChoices = [];
|
|
74
74
|
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
const scanDir = async (dir, rootDir) => {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const fullPath = path.join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
await scanDir(fullPath, rootDir);
|
|
87
|
+
}
|
|
88
|
+
else if (entry.name.endsWith('.json')) {
|
|
89
|
+
let label = path.relative(rootDir, fullPath);
|
|
90
|
+
try {
|
|
91
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
92
|
+
const parsed = JSON.parse(raw);
|
|
93
|
+
if (parsed.name)
|
|
94
|
+
label = `${parsed.name} (${label})`;
|
|
85
95
|
}
|
|
96
|
+
catch { /* use path as label */ }
|
|
97
|
+
localChoices.push({
|
|
98
|
+
name: `${theme.value('[LOCAL]')} ${label}`,
|
|
99
|
+
value: { type: 'local', path: fullPath }
|
|
100
|
+
});
|
|
86
101
|
}
|
|
87
102
|
}
|
|
88
|
-
}
|
|
103
|
+
};
|
|
104
|
+
if (existsSync(workflowsDir))
|
|
105
|
+
await scanDir(workflowsDir, workflowsDir);
|
|
89
106
|
const remoteWorkflows = await client.getWorkflows();
|
|
90
107
|
const remoteChoices = remoteWorkflows
|
|
91
108
|
.map(w => ({
|
|
@@ -119,17 +136,8 @@ export default class Modify extends Command {
|
|
|
119
136
|
}
|
|
120
137
|
// 3. Get Instruction
|
|
121
138
|
let instruction = args.instruction;
|
|
122
|
-
if (!instruction && flags.multiline) {
|
|
123
|
-
const response = await inquirer.prompt([{
|
|
124
|
-
type: 'editor',
|
|
125
|
-
name: 'instruction',
|
|
126
|
-
message: 'Describe the modifications you want to apply (opens editor):',
|
|
127
|
-
validate: (d) => d.trim().length > 0
|
|
128
|
-
}]);
|
|
129
|
-
instruction = response.instruction;
|
|
130
|
-
}
|
|
131
139
|
if (!instruction) {
|
|
132
|
-
instruction = await promptMultiline('Describe the modifications you want to apply:');
|
|
140
|
+
instruction = await promptMultiline('Describe the modifications you want to apply (use ``` for multiline): ');
|
|
133
141
|
}
|
|
134
142
|
if (!instruction) {
|
|
135
143
|
this.error('Modification instructions are required.');
|
|
@@ -157,51 +165,130 @@ export default class Modify extends Command {
|
|
|
157
165
|
const nodeName = Object.keys(event)[0];
|
|
158
166
|
const stateUpdate = event[nodeName];
|
|
159
167
|
if (nodeName === 'architect') {
|
|
160
|
-
this.log(theme.agent(`🏗️ Architect:
|
|
161
|
-
if (stateUpdate.spec) {
|
|
162
|
-
this.log(` Plan: ${theme.value(stateUpdate.spec.suggestedName || 'Modifying structure')}`);
|
|
163
|
-
}
|
|
168
|
+
this.log(theme.agent(`🏗️ Architect: Modification plan ready.`));
|
|
164
169
|
}
|
|
165
170
|
else if (nodeName === 'engineer') {
|
|
166
171
|
this.log(theme.agent(`⚙️ Engineer: Applying changes to workflow...`));
|
|
167
|
-
if (stateUpdate.workflowJson)
|
|
172
|
+
if (stateUpdate.workflowJson)
|
|
168
173
|
lastWorkflowJson = stateUpdate.workflowJson;
|
|
169
|
-
}
|
|
170
174
|
}
|
|
171
175
|
else if (nodeName === 'qa') {
|
|
172
|
-
|
|
173
|
-
if (status === 'passed') {
|
|
176
|
+
if (stateUpdate.validationStatus === 'passed') {
|
|
174
177
|
this.log(theme.success(`🧪 QA: Modification Validated.`));
|
|
175
178
|
}
|
|
176
179
|
else {
|
|
177
180
|
this.log(theme.fail(`🧪 QA: Validation Issues Found.`));
|
|
178
|
-
if (stateUpdate.validationErrors
|
|
181
|
+
if (stateUpdate.validationErrors?.length) {
|
|
179
182
|
stateUpdate.validationErrors.forEach((e) => this.log(theme.error(` - ${e}`)));
|
|
180
183
|
}
|
|
181
184
|
this.log(theme.warn(` Looping back for refinements...`));
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
}
|
|
185
|
-
// HITL
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
188
|
+
// HITL loop — same pattern as create.ts
|
|
189
|
+
let snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
190
|
+
while (snapshot.next.length > 0) {
|
|
191
|
+
const nextNode = snapshot.next[0];
|
|
192
|
+
if (nextNode === 'engineer') {
|
|
193
|
+
const isRepair = (snapshot.values.validationErrors || []).length > 0;
|
|
194
|
+
if (isRepair) {
|
|
195
|
+
// Repair iteration — auto-continue
|
|
196
|
+
const repairStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
197
|
+
for await (const event of repairStream) {
|
|
198
|
+
const n = Object.keys(event)[0];
|
|
199
|
+
const u = event[n];
|
|
200
|
+
if (n === 'engineer' && u.workflowJson)
|
|
201
|
+
lastWorkflowJson = u.workflowJson;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Show modification plan and give options
|
|
206
|
+
const plan = snapshot.values.spec;
|
|
207
|
+
if (plan) {
|
|
208
|
+
this.log(theme.header('\nMODIFICATION PLAN:'));
|
|
209
|
+
this.log(` ${theme.value(plan.description || '')}`);
|
|
210
|
+
if (plan.proposedChanges?.length) {
|
|
211
|
+
this.log(theme.info('\nProposed Changes:'));
|
|
212
|
+
plan.proposedChanges.forEach(c => this.log(` ${theme.muted('•')} ${c}`));
|
|
213
|
+
}
|
|
214
|
+
if (plan.affectedNodes?.length) {
|
|
215
|
+
this.log(`\n${theme.label('Affected Nodes')} ${plan.affectedNodes.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
this.log('');
|
|
218
|
+
}
|
|
219
|
+
const choices = [
|
|
220
|
+
{ name: 'Proceed with this plan', value: { type: 'proceed' } },
|
|
221
|
+
{ name: 'Add feedback before modifying', value: { type: 'feedback' } },
|
|
222
|
+
new inquirer.Separator(),
|
|
223
|
+
{ name: 'Exit (discard)', value: { type: 'exit' } },
|
|
224
|
+
];
|
|
225
|
+
const { choice } = await inquirer.prompt([{
|
|
226
|
+
type: 'list',
|
|
227
|
+
name: 'choice',
|
|
228
|
+
message: 'Blueprint ready. How would you like to proceed?',
|
|
229
|
+
choices,
|
|
230
|
+
}]);
|
|
231
|
+
if (choice.type === 'exit') {
|
|
232
|
+
this.log(theme.info(`\nSession saved. Resume later with: n8m resume ${threadId}`));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let stateUpdate = {};
|
|
236
|
+
if (choice.type === 'feedback') {
|
|
237
|
+
const { feedback } = await inquirer.prompt([{
|
|
238
|
+
type: 'input',
|
|
239
|
+
name: 'feedback',
|
|
240
|
+
message: 'Describe your refinements:',
|
|
241
|
+
}]);
|
|
242
|
+
stateUpdate = { userFeedback: feedback };
|
|
243
|
+
this.log(theme.agent(`Feedback noted. Applying modifications with your refinements...`));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
this.log(theme.agent(`⚙️ Engineer: Applying modifications...`));
|
|
247
|
+
}
|
|
248
|
+
if (Object.keys(stateUpdate).length > 0) {
|
|
249
|
+
await graph.updateState({ configurable: { thread_id: threadId } }, stateUpdate);
|
|
250
|
+
}
|
|
251
|
+
const engineerStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
252
|
+
for await (const event of engineerStream) {
|
|
253
|
+
const n = Object.keys(event)[0];
|
|
254
|
+
const u = event[n];
|
|
255
|
+
if (n === 'engineer' && u.workflowJson)
|
|
256
|
+
lastWorkflowJson = u.workflowJson;
|
|
257
|
+
else if (n === 'reviewer' && u.validationStatus === 'failed') {
|
|
258
|
+
this.log(theme.warn(` Reviewer flagged issues — Engineer will revise...`));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (nextNode === 'qa') {
|
|
264
|
+
this.log(theme.agent(`⚙️ Running QA validation...`));
|
|
265
|
+
const qaStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
266
|
+
for await (const event of qaStream) {
|
|
267
|
+
const n = Object.keys(event)[0];
|
|
268
|
+
const u = event[n];
|
|
269
|
+
if (n === 'qa') {
|
|
270
|
+
if (u.validationStatus === 'passed') {
|
|
271
|
+
this.log(theme.success(`🧪 QA: Validation Passed.`));
|
|
272
|
+
if (u.workflowJson)
|
|
273
|
+
lastWorkflowJson = u.workflowJson;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
this.log(theme.fail(`🧪 QA: Validation Failed.`));
|
|
277
|
+
if (u.validationErrors?.length) {
|
|
278
|
+
u.validationErrors.forEach(e => this.log(theme.error(` - ${e}`)));
|
|
279
|
+
}
|
|
280
|
+
this.log(theme.warn(` Looping back to Engineer for repairs...`));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
200
284
|
}
|
|
201
285
|
else {
|
|
202
|
-
|
|
203
|
-
|
|
286
|
+
// Unknown interrupt — auto-resume
|
|
287
|
+
const result = await resumeAgenticWorkflow(threadId, null);
|
|
288
|
+
if (result.workflowJson)
|
|
289
|
+
lastWorkflowJson = result.workflowJson;
|
|
204
290
|
}
|
|
291
|
+
snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
205
292
|
}
|
|
206
293
|
}
|
|
207
294
|
catch (error) {
|
|
@@ -222,44 +309,98 @@ export default class Modify extends Command {
|
|
|
222
309
|
if (!modifiedWorkflow.name.toLowerCase().includes('modified')) {
|
|
223
310
|
// maybe add a suffix? Or just keep it.
|
|
224
311
|
}
|
|
312
|
+
// Find an existing local file matching by workflow ID or name
|
|
313
|
+
const findExistingLocalPath = async () => {
|
|
314
|
+
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
315
|
+
const searchId = modifiedWorkflow.id || workflowData.id;
|
|
316
|
+
const searchName = modifiedWorkflow.name || workflowName;
|
|
317
|
+
const search = async (dir) => {
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
const fullPath = path.join(dir, entry.name);
|
|
327
|
+
if (entry.isDirectory()) {
|
|
328
|
+
const found = await search(fullPath);
|
|
329
|
+
if (found)
|
|
330
|
+
return found;
|
|
331
|
+
}
|
|
332
|
+
else if (entry.name.endsWith('.json')) {
|
|
333
|
+
try {
|
|
334
|
+
const parsed = JSON.parse(await fs.readFile(fullPath, 'utf-8'));
|
|
335
|
+
if ((searchId && parsed.id === searchId) || parsed.name === searchName)
|
|
336
|
+
return fullPath;
|
|
337
|
+
}
|
|
338
|
+
catch { /* skip */ }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
return search(workflowsDir);
|
|
343
|
+
};
|
|
344
|
+
const saveLocally = async (promptPath = true) => {
|
|
345
|
+
const existingPath = originalPath || await findExistingLocalPath();
|
|
346
|
+
const defaultPath = flags.output || existingPath || path.join(process.cwd(), 'workflows', `${workflowName}.json`);
|
|
347
|
+
let targetPath = defaultPath;
|
|
348
|
+
if (promptPath && !existingPath) {
|
|
349
|
+
const { p } = await inquirer.prompt([{
|
|
350
|
+
type: 'input',
|
|
351
|
+
name: 'p',
|
|
352
|
+
message: 'Save modified workflow to:',
|
|
353
|
+
default: defaultPath
|
|
354
|
+
}]);
|
|
355
|
+
targetPath = p;
|
|
356
|
+
}
|
|
357
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
358
|
+
await fs.writeFile(targetPath, JSON.stringify(modifiedWorkflow, null, 2));
|
|
359
|
+
this.log(theme.success(`✔ Saved to ${targetPath}`));
|
|
360
|
+
};
|
|
225
361
|
const { action } = await inquirer.prompt([{
|
|
226
362
|
type: 'select',
|
|
227
363
|
name: 'action',
|
|
228
364
|
message: 'Modification complete. What would you like to do?',
|
|
229
365
|
choices: [
|
|
230
|
-
{ name: '
|
|
231
|
-
{ name: '
|
|
366
|
+
{ name: 'Deploy to n8n instance (also saves locally)', value: 'deploy' },
|
|
367
|
+
{ name: 'Save locally only', value: 'save' },
|
|
232
368
|
{ name: 'Run ephemeral test (n8m test)', value: 'test' },
|
|
233
369
|
{ name: 'Discard changes', value: 'discard' }
|
|
234
370
|
]
|
|
235
371
|
}]);
|
|
236
372
|
if (action === 'save') {
|
|
237
|
-
|
|
238
|
-
const { targetPath } = await inquirer.prompt([{
|
|
239
|
-
type: 'input',
|
|
240
|
-
name: 'targetPath',
|
|
241
|
-
message: 'Save modified workflow to:',
|
|
242
|
-
default: defaultPath
|
|
243
|
-
}]);
|
|
244
|
-
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
245
|
-
await fs.writeFile(targetPath, JSON.stringify(modifiedWorkflow, null, 2));
|
|
246
|
-
this.log(theme.success(`✔ Saved to ${targetPath}`));
|
|
373
|
+
await saveLocally();
|
|
247
374
|
}
|
|
248
375
|
else if (action === 'deploy') {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
376
|
+
const targetId = remoteId || modifiedWorkflow.id;
|
|
377
|
+
if (targetId) {
|
|
378
|
+
let existsRemotely = false;
|
|
379
|
+
try {
|
|
380
|
+
await client.getWorkflow(targetId);
|
|
381
|
+
existsRemotely = true;
|
|
382
|
+
}
|
|
383
|
+
catch { /* not found */ }
|
|
384
|
+
if (existsRemotely) {
|
|
385
|
+
this.log(theme.info(`Updating remote workflow ${targetId}...`));
|
|
386
|
+
await client.updateWorkflow(targetId, modifiedWorkflow);
|
|
387
|
+
this.log(theme.success(`✔ Remote workflow updated.`));
|
|
388
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(targetId))}`);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const result = await client.createWorkflow(modifiedWorkflow.name, modifiedWorkflow);
|
|
392
|
+
modifiedWorkflow.id = result.id;
|
|
393
|
+
this.log(theme.success(`✔ Created workflow [ID: ${result.id}]`));
|
|
394
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
395
|
+
}
|
|
253
396
|
}
|
|
254
397
|
else {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
// Remove ID to ensure a fresh creation
|
|
258
|
-
delete payload.id;
|
|
259
|
-
const result = await client.createWorkflow(payload.name, payload);
|
|
398
|
+
const result = await client.createWorkflow(modifiedWorkflow.name, modifiedWorkflow);
|
|
399
|
+
modifiedWorkflow.id = result.id;
|
|
260
400
|
this.log(theme.success(`✔ Created workflow [ID: ${result.id}]`));
|
|
261
401
|
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
262
402
|
}
|
|
403
|
+
await saveLocally(false);
|
|
263
404
|
}
|
|
264
405
|
else if (action === 'test') {
|
|
265
406
|
// Automatically run the test command
|
|
@@ -272,5 +413,6 @@ export default class Modify extends Command {
|
|
|
272
413
|
await this.config.runCommand('test', [tempPath]);
|
|
273
414
|
}
|
|
274
415
|
this.log(theme.done('Modification Process Complete.'));
|
|
416
|
+
process.exit(0);
|
|
275
417
|
}
|
|
276
418
|
}
|
package/dist/commands/test.d.ts
CHANGED
|
@@ -81,6 +81,10 @@ export default class Test extends Command {
|
|
|
81
81
|
*/
|
|
82
82
|
private sanitizeMockPayload;
|
|
83
83
|
private deployWorkflows;
|
|
84
|
+
/** Run all fixture files in a directory as a suite. */
|
|
85
|
+
private runFixtureSuite;
|
|
86
|
+
/** Run an in-memory list of fixtures as a suite and print a summary. */
|
|
87
|
+
private runFixtureSuiteFromList;
|
|
84
88
|
private offerSaveFixture;
|
|
85
89
|
private testWithFixture;
|
|
86
90
|
private findPredecessorNode;
|
package/dist/commands/test.js
CHANGED
|
@@ -372,30 +372,47 @@ export default class Test extends Command {
|
|
|
372
372
|
let directResult;
|
|
373
373
|
const fixtureFlagPath = flags['fixture'];
|
|
374
374
|
if (fixtureFlagPath) {
|
|
375
|
-
// --fixture flag:
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
375
|
+
// --fixture flag: directory = run all as suite; file = single fixture
|
|
376
|
+
const { statSync } = await import('fs');
|
|
377
|
+
let isDir = false;
|
|
378
|
+
try {
|
|
379
|
+
isDir = statSync(fixtureFlagPath).isDirectory();
|
|
380
|
+
}
|
|
381
|
+
catch { /* not found */ }
|
|
382
|
+
if (isDir) {
|
|
383
|
+
directResult = await this.runFixtureSuite(fixtureFlagPath, fixtureManager, workflowName, aiService);
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
const fixture = fixtureManager.loadFromPath(fixtureFlagPath);
|
|
387
|
+
if (!fixture) {
|
|
388
|
+
this.log(theme.fail(`Could not load fixture from: ${fixtureFlagPath}`));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
directResult = await this.testWithFixture(fixture, workflowName, aiService);
|
|
380
392
|
}
|
|
381
|
-
directResult = await this.testWithFixture(fixture, workflowName, aiService);
|
|
382
393
|
}
|
|
383
394
|
else {
|
|
384
395
|
const capturedDate = fixtureManager.getCapturedDate(rootRealTargetId);
|
|
385
396
|
if (capturedDate && !validateOnly) {
|
|
397
|
+
const allFixtures = fixtureManager.loadAll(rootRealTargetId);
|
|
386
398
|
const dateStr = capturedDate.toLocaleString('en-US', {
|
|
387
399
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
388
400
|
hour: '2-digit', minute: '2-digit',
|
|
389
401
|
});
|
|
402
|
+
const caseLabel = allFixtures.length === 1 ? '1 fixture' : `${allFixtures.length} fixture cases`;
|
|
390
403
|
const { useFixture } = await inquirer.prompt([{
|
|
391
404
|
type: 'confirm',
|
|
392
405
|
name: 'useFixture',
|
|
393
|
-
message:
|
|
406
|
+
message: `${caseLabel} found from ${dateStr}. Run offline?`,
|
|
394
407
|
default: true,
|
|
395
408
|
}]);
|
|
396
409
|
if (useFixture) {
|
|
397
|
-
|
|
398
|
-
|
|
410
|
+
if (allFixtures.length === 1) {
|
|
411
|
+
directResult = await this.testWithFixture(allFixtures[0], workflowName, aiService);
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
directResult = await this.runFixtureSuiteFromList(allFixtures, workflowName, aiService);
|
|
415
|
+
}
|
|
399
416
|
}
|
|
400
417
|
else {
|
|
401
418
|
directResult = await this.testRemoteWorkflowDirectly(rootRealTargetId, workflowData, workflowName, client, aiService, n8nUrl, testScenarios);
|
|
@@ -406,8 +423,13 @@ export default class Test extends Command {
|
|
|
406
423
|
}
|
|
407
424
|
else if (capturedDate && validateOnly) {
|
|
408
425
|
// validate-only + fixture: use fixture silently (no prompt)
|
|
409
|
-
const
|
|
410
|
-
|
|
426
|
+
const allFixtures = fixtureManager.loadAll(rootRealTargetId);
|
|
427
|
+
if (allFixtures.length === 1) {
|
|
428
|
+
directResult = await this.testWithFixture(allFixtures[0], workflowName, aiService);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
directResult = await this.runFixtureSuiteFromList(allFixtures, workflowName, aiService);
|
|
432
|
+
}
|
|
411
433
|
}
|
|
412
434
|
else {
|
|
413
435
|
directResult = await this.testRemoteWorkflowDirectly(rootRealTargetId, workflowData, workflowName, client, aiService, n8nUrl, testScenarios);
|
|
@@ -590,7 +612,7 @@ export default class Test extends Command {
|
|
|
590
612
|
}
|
|
591
613
|
return { ...workflowData, nodes, connections };
|
|
592
614
|
}
|
|
593
|
-
async saveWorkflows(deployedDefinitions,
|
|
615
|
+
async saveWorkflows(deployedDefinitions, originalPath) {
|
|
594
616
|
if (deployedDefinitions.size === 0)
|
|
595
617
|
return;
|
|
596
618
|
const { save } = await inquirer.prompt([{
|
|
@@ -618,8 +640,24 @@ export default class Test extends Command {
|
|
|
618
640
|
type: 'input',
|
|
619
641
|
name: 'confirmPath',
|
|
620
642
|
message: `Save '${workflowName}' to:`,
|
|
621
|
-
default: targetPath
|
|
643
|
+
default: originalPath ?? targetPath
|
|
622
644
|
}]);
|
|
645
|
+
// Restore the original deployed ID if we're overwriting an existing file,
|
|
646
|
+
// so the ephemeral test ID doesn't replace the production deployment link.
|
|
647
|
+
const resolvedConfirmPath = path.resolve(confirmPath);
|
|
648
|
+
const resolvedOriginal = originalPath ? path.resolve(originalPath) : null;
|
|
649
|
+
if (resolvedConfirmPath === resolvedOriginal && originalPath) {
|
|
650
|
+
try {
|
|
651
|
+
const existing = JSON.parse(await fs.readFile(originalPath, 'utf-8'));
|
|
652
|
+
if (existing.id)
|
|
653
|
+
cleanData.id = existing.id;
|
|
654
|
+
}
|
|
655
|
+
catch { /* original file unreadable — leave cleanData as-is */ }
|
|
656
|
+
}
|
|
657
|
+
else if (!resolvedOriginal) {
|
|
658
|
+
// New file — strip ephemeral ID so next deploy creates fresh
|
|
659
|
+
delete cleanData.id;
|
|
660
|
+
}
|
|
623
661
|
try {
|
|
624
662
|
await fs.mkdir(path.dirname(confirmPath), { recursive: true });
|
|
625
663
|
await fs.writeFile(confirmPath, JSON.stringify(cleanData, null, 2));
|
|
@@ -1197,13 +1235,27 @@ Previous error: "${errSnapshot}"`;
|
|
|
1197
1235
|
}
|
|
1198
1236
|
// Remove injected shim nodes and restore original connections.
|
|
1199
1237
|
if (preShimNodes !== null) {
|
|
1238
|
+
// Merge: restore original node definitions for shimmed nodes, but keep
|
|
1239
|
+
// any legitimate Code node repairs that happened during the test run.
|
|
1240
|
+
const restoredNodes = preShimNodes.map((originalNode) => {
|
|
1241
|
+
const currentNode = currentWorkflow.nodes.find((n) => n.id === originalNode.id);
|
|
1242
|
+
if (!currentNode)
|
|
1243
|
+
return originalNode;
|
|
1244
|
+
const isShimReplacement = currentNode.type === 'n8n-nodes-base.code' &&
|
|
1245
|
+
typeof currentNode.parameters?.jsCode === 'string' &&
|
|
1246
|
+
currentNode.parameters.jsCode.startsWith('// [n8m:shim]');
|
|
1247
|
+
return isShimReplacement ? originalNode : currentNode;
|
|
1248
|
+
});
|
|
1200
1249
|
try {
|
|
1201
1250
|
await client.updateWorkflow(workflowId, {
|
|
1202
1251
|
name: currentWorkflow.name,
|
|
1203
|
-
nodes:
|
|
1252
|
+
nodes: restoredNodes,
|
|
1204
1253
|
connections: preShimConnections,
|
|
1205
1254
|
settings: currentWorkflow.settings || {},
|
|
1206
1255
|
});
|
|
1256
|
+
// Also restore in-memory so finalWorkflow/saveWorkflows gets production nodes
|
|
1257
|
+
currentWorkflow.nodes = restoredNodes;
|
|
1258
|
+
currentWorkflow.connections = preShimConnections;
|
|
1207
1259
|
}
|
|
1208
1260
|
catch { /* restore best-effort */ }
|
|
1209
1261
|
}
|
|
@@ -1551,6 +1603,48 @@ Previous error: "${errSnapshot}"`;
|
|
|
1551
1603
|
// ---------------------------------------------------------------------------
|
|
1552
1604
|
// Fixture helpers
|
|
1553
1605
|
// ---------------------------------------------------------------------------
|
|
1606
|
+
/** Run all fixture files in a directory as a suite. */
|
|
1607
|
+
async runFixtureSuite(dirPath, fixtureManager, workflowName, aiService) {
|
|
1608
|
+
const { readdirSync } = await import('fs');
|
|
1609
|
+
const files = readdirSync(dirPath).filter((f) => f.endsWith('.json')).sort();
|
|
1610
|
+
if (files.length === 0) {
|
|
1611
|
+
this.log(theme.fail(`No fixture files found in ${dirPath}`));
|
|
1612
|
+
return { passed: false, errors: ['No fixture files found'] };
|
|
1613
|
+
}
|
|
1614
|
+
const fixtures = files.flatMap((f) => {
|
|
1615
|
+
const loaded = fixtureManager.loadFromPath(path.join(dirPath, f));
|
|
1616
|
+
return loaded ? [loaded] : [];
|
|
1617
|
+
});
|
|
1618
|
+
return this.runFixtureSuiteFromList(fixtures, workflowName, aiService);
|
|
1619
|
+
}
|
|
1620
|
+
/** Run an in-memory list of fixtures as a suite and print a summary. */
|
|
1621
|
+
async runFixtureSuiteFromList(fixtures, workflowName, aiService) {
|
|
1622
|
+
this.log(theme.subHeader(`Running ${fixtures.length} fixture case(s) for ${workflowName}`));
|
|
1623
|
+
const results = [];
|
|
1624
|
+
for (const fixture of fixtures) {
|
|
1625
|
+
const label = fixture.description ?? fixture.execution?.status ?? 'case';
|
|
1626
|
+
const expected = fixture.expectedOutcome ?? 'pass';
|
|
1627
|
+
this.log(`\n${theme.label('Case')} ${theme.value(label)} ${theme.muted(`(expected: ${expected})`)}`);
|
|
1628
|
+
const result = await this.testWithFixture(fixture, workflowName, aiService);
|
|
1629
|
+
results.push({ label, passed: result.passed, error: result.errors[0] });
|
|
1630
|
+
}
|
|
1631
|
+
// Summary table
|
|
1632
|
+
this.log(`\n${theme.subHeader('Suite Results')}`);
|
|
1633
|
+
let suitePass = true;
|
|
1634
|
+
for (const r of results) {
|
|
1635
|
+
if (r.passed) {
|
|
1636
|
+
this.log(` ${theme.done(r.label)}`);
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
this.log(` ${theme.fail(r.label)}${r.error ? theme.muted(` — ${r.error}`) : ''}`);
|
|
1640
|
+
suitePass = false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
const passCount = results.filter(r => r.passed).length;
|
|
1644
|
+
this.log(`\n${theme.label('Total')} ${theme.value(`${passCount}/${results.length} passed`)}`);
|
|
1645
|
+
const allErrors = results.filter(r => !r.passed).map(r => r.error ?? r.label);
|
|
1646
|
+
return { passed: suitePass, errors: allErrors };
|
|
1647
|
+
}
|
|
1554
1648
|
async offerSaveFixture(fixtureManager, workflowId, workflowName, finalWorkflow, lastExecution) {
|
|
1555
1649
|
const { saveFixture } = await inquirer.prompt([{
|
|
1556
1650
|
type: 'confirm',
|
|
@@ -1622,10 +1716,20 @@ Previous error: "${errSnapshot}"`;
|
|
|
1622
1716
|
? (failingNode ? `[${failingNode}] ${rawMsg}` : rawMsg)
|
|
1623
1717
|
: null;
|
|
1624
1718
|
}
|
|
1719
|
+
const expectedOutcome = fixture.expectedOutcome ?? 'pass';
|
|
1625
1720
|
if (!fixtureError) {
|
|
1721
|
+
if (expectedOutcome === 'fail') {
|
|
1722
|
+
const msg = `Expected execution to fail, but it succeeded.`;
|
|
1723
|
+
this.log(theme.fail(msg));
|
|
1724
|
+
return { passed: false, errors: [msg], finalWorkflow: currentWorkflow };
|
|
1725
|
+
}
|
|
1626
1726
|
this.log(theme.done('Offline fixture: execution was successful.'));
|
|
1627
1727
|
return { passed: true, errors: [], finalWorkflow: currentWorkflow };
|
|
1628
1728
|
}
|
|
1729
|
+
if (expectedOutcome === 'fail') {
|
|
1730
|
+
this.log(theme.done(`Expected failure confirmed: ${fixtureError}`));
|
|
1731
|
+
return { passed: true, errors: [], finalWorkflow: currentWorkflow };
|
|
1732
|
+
}
|
|
1629
1733
|
this.log(theme.agent(`Fixture captured a failure: ${fixtureError}`));
|
|
1630
1734
|
const lastError = fixtureError;
|
|
1631
1735
|
let scenarioPassed = false;
|
|
@@ -2,6 +2,7 @@ export interface GenerateOptions {
|
|
|
2
2
|
model?: string;
|
|
3
3
|
provider?: string;
|
|
4
4
|
temperature?: number;
|
|
5
|
+
maxTokens?: number;
|
|
5
6
|
}
|
|
6
7
|
export interface TestErrorEvaluation {
|
|
7
8
|
action: 'fix_node' | 'regenerate_payload' | 'structural_pass' | 'escalate';
|
|
@@ -44,16 +45,48 @@ export declare class AIService {
|
|
|
44
45
|
getDefaultModel(): string;
|
|
45
46
|
getDefaultProvider(): string;
|
|
46
47
|
generateSpec(goal: string): Promise<WorkflowSpec>;
|
|
48
|
+
chatAboutSpec(spec: WorkflowSpec, history: {
|
|
49
|
+
role: 'user' | 'assistant';
|
|
50
|
+
content: string;
|
|
51
|
+
}[], userMessage: string): Promise<{
|
|
52
|
+
reply: string;
|
|
53
|
+
updatedSpec: WorkflowSpec;
|
|
54
|
+
}>;
|
|
47
55
|
generateWorkflow(goal: string): Promise<any>;
|
|
48
56
|
generateAlternativeSpec(goal: string, primarySpec: WorkflowSpec): Promise<WorkflowSpec>;
|
|
57
|
+
generateModificationPlan(instruction: string, workflowJson: any): Promise<any>;
|
|
58
|
+
applyModification(workflowJson: any, userGoal: string, spec: any, userFeedback?: string, validNodeTypes?: string[]): Promise<any>;
|
|
59
|
+
/**
|
|
60
|
+
* Merge connections from the original workflow into the modified one for any
|
|
61
|
+
* nodes that exist in both but lost their connections during LLM generation.
|
|
62
|
+
* Then does a position-based stitch for any remaining nodes with no outgoing
|
|
63
|
+
* main connection, using canvas x/y position to infer the intended chain order.
|
|
64
|
+
*/
|
|
65
|
+
private repairConnections;
|
|
49
66
|
generateWorkflowFix(workflow: any, error: string, model?: string, _stream?: boolean, validNodeTypes?: string[]): Promise<any>;
|
|
50
67
|
validateAndShim(workflow: any, validNodeTypes?: string[], explicitlyInvalid?: string[]): any;
|
|
51
68
|
fixHallucinatedNodes(workflow: any): any;
|
|
69
|
+
/**
|
|
70
|
+
* Wire orphaned error-handler nodes that the LLM created but forgot to connect.
|
|
71
|
+
* Detects nodes with no incoming connections whose name suggests they are error
|
|
72
|
+
* handlers (contains "Error", "Cleanup", "Rollback", "Fallback", etc.) and wires
|
|
73
|
+
* every non-terminal, non-handler node's error output to them.
|
|
74
|
+
* Also sets onError:"continueErrorOutput" on each wired source node.
|
|
75
|
+
*/
|
|
76
|
+
wireOrphanedErrorHandlers(workflow: any): any;
|
|
52
77
|
fixN8nConnections(workflow: any): any;
|
|
53
78
|
generateMockData(context: string): Promise<any>;
|
|
54
79
|
fixExecuteCommandScript(command: string, error?: string): Promise<string>;
|
|
55
80
|
fixCodeNodeJavaScript(code: string, error: string): Promise<string>;
|
|
56
81
|
shimCodeNodeWithMockData(code: string): Promise<string>;
|
|
82
|
+
/**
|
|
83
|
+
* Analyze a validated working workflow and generate a reusable pattern file.
|
|
84
|
+
* Returns markdown content ready to save to docs/patterns/.
|
|
85
|
+
*/
|
|
86
|
+
generatePattern(workflowJson: any): Promise<{
|
|
87
|
+
content: string;
|
|
88
|
+
slug: string;
|
|
89
|
+
}>;
|
|
57
90
|
evaluateCandidates(goal: string, candidates: any[]): Promise<{
|
|
58
91
|
selectedIndex: number;
|
|
59
92
|
reason: string;
|