@lhi/n8m 0.2.3 → 0.3.0

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.
@@ -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 searchDirs = [workflowsDir, process.cwd()];
76
- for (const dir of searchDirs) {
77
- if (existsSync(dir)) {
78
- const files = await fs.readdir(dir);
79
- for (const file of files) {
80
- if (file.endsWith('.json')) {
81
- localChoices.push({
82
- name: `${theme.value('[LOCAL]')} ${file}`,
83
- value: { type: 'local', path: path.join(dir, file) }
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: Analysis complete.`));
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
- const status = stateUpdate.validationStatus;
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 && stateUpdate.validationErrors.length > 0) {
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 Pause
186
- const snapshot = await graph.getState({ configurable: { thread_id: threadId } });
187
- if (snapshot.next.length > 0) {
188
- this.log(theme.warn(`\n⏸️ Modification Paused at step: ${snapshot.next.join(', ')}`));
189
- const { resume } = await inquirer.prompt([{
190
- type: 'confirm',
191
- name: 'resume',
192
- message: 'Review pending changes. Proceed with finalization?',
193
- default: true
194
- }]);
195
- if (resume) {
196
- this.log(theme.agent("Finalizing..."));
197
- const result = await resumeAgenticWorkflow(threadId);
198
- if (result.workflowJson)
199
- lastWorkflowJson = result.workflowJson;
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
- this.log(theme.info(`Session persisted. Thread: ${threadId}`));
203
- return;
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: 'Save locally', value: 'save' },
231
- { name: 'Deploy to n8n instance', value: 'deploy' },
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
- const defaultPath = flags.output || originalPath || path.join(process.cwd(), 'workflows', `${workflowName}-modified.json`);
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
- if (remoteId) {
250
- this.log(theme.info(`Updating remote workflow ${remoteId}...`));
251
- await client.updateWorkflow(remoteId, modifiedWorkflow);
252
- this.log(theme.success(`✔ Remote workflow updated.`));
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
- this.log(theme.info(`Creating new workflow on instance...`));
256
- const payload = { ...modifiedWorkflow };
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
  }
@@ -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;
@@ -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: load from explicit path, run offline immediately (no prompt)
376
- const fixture = fixtureManager.loadFromPath(fixtureFlagPath);
377
- if (!fixture) {
378
- this.log(theme.fail(`Could not load fixture from: ${fixtureFlagPath}`));
379
- return;
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: `Fixture found from ${dateStr}. Run offline?`,
406
+ message: `${caseLabel} found from ${dateStr}. Run offline?`,
394
407
  default: true,
395
408
  }]);
396
409
  if (useFixture) {
397
- const fixture = fixtureManager.load(rootRealTargetId);
398
- directResult = await this.testWithFixture(fixture, workflowName, aiService);
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 fixture = fixtureManager.load(rootRealTargetId);
410
- directResult = await this.testWithFixture(fixture, workflowName, aiService);
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, _originalPath) {
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: preShimNodes,
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;