@lhi/n8m 0.1.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.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/bin/dev.js +5 -0
- package/bin/run.js +6 -0
- package/dist/agentic/checkpointer.d.ts +2 -0
- package/dist/agentic/checkpointer.js +14 -0
- package/dist/agentic/graph.d.ts +483 -0
- package/dist/agentic/graph.js +100 -0
- package/dist/agentic/nodes/architect.d.ts +6 -0
- package/dist/agentic/nodes/architect.js +51 -0
- package/dist/agentic/nodes/engineer.d.ts +11 -0
- package/dist/agentic/nodes/engineer.js +182 -0
- package/dist/agentic/nodes/qa.d.ts +5 -0
- package/dist/agentic/nodes/qa.js +151 -0
- package/dist/agentic/nodes/reviewer.d.ts +5 -0
- package/dist/agentic/nodes/reviewer.js +111 -0
- package/dist/agentic/nodes/supervisor.d.ts +6 -0
- package/dist/agentic/nodes/supervisor.js +18 -0
- package/dist/agentic/state.d.ts +51 -0
- package/dist/agentic/state.js +26 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/create.d.ts +14 -0
- package/dist/commands/create.js +182 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.js +68 -0
- package/dist/commands/modify.d.ts +13 -0
- package/dist/commands/modify.js +276 -0
- package/dist/commands/prune.d.ts +9 -0
- package/dist/commands/prune.js +98 -0
- package/dist/commands/resume.d.ts +8 -0
- package/dist/commands/resume.js +39 -0
- package/dist/commands/test.d.ts +27 -0
- package/dist/commands/test.js +619 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/ai.service.d.ts +51 -0
- package/dist/services/ai.service.js +421 -0
- package/dist/services/n8n.service.d.ts +17 -0
- package/dist/services/n8n.service.js +81 -0
- package/dist/services/node-definitions.service.d.ts +36 -0
- package/dist/services/node-definitions.service.js +102 -0
- package/dist/utils/config.d.ts +15 -0
- package/dist/utils/config.js +25 -0
- package/dist/utils/multilinePrompt.d.ts +1 -0
- package/dist/utils/multilinePrompt.js +52 -0
- package/dist/utils/n8nClient.d.ts +97 -0
- package/dist/utils/n8nClient.js +440 -0
- package/dist/utils/sandbox.d.ts +13 -0
- package/dist/utils/sandbox.js +34 -0
- package/dist/utils/theme.d.ts +23 -0
- package/dist/utils/theme.js +92 -0
- package/oclif.manifest.json +331 -0
- package/package.json +95 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
3
|
+
import { theme } from '../utils/theme.js';
|
|
4
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
5
|
+
import { ConfigManager } from '../utils/config.js';
|
|
6
|
+
import { runAgenticWorkflow, graph, resumeAgenticWorkflow } from '../agentic/graph.js';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
export default class Test extends Command {
|
|
11
|
+
static args = {
|
|
12
|
+
workflow: Args.string({
|
|
13
|
+
description: 'Path to workflow JSON file',
|
|
14
|
+
required: false,
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
static description = 'Run ephemeral end-to-end tests for n8n workflows';
|
|
18
|
+
static flags = {
|
|
19
|
+
headless: Flags.boolean({
|
|
20
|
+
char: 'h',
|
|
21
|
+
default: true,
|
|
22
|
+
description: 'Run tests in headless mode',
|
|
23
|
+
}),
|
|
24
|
+
'keep-on-fail': Flags.boolean({
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Do not delete workflow if test fails',
|
|
27
|
+
}),
|
|
28
|
+
'no-brand': Flags.boolean({
|
|
29
|
+
default: false,
|
|
30
|
+
hidden: true,
|
|
31
|
+
description: 'Suppress branding header',
|
|
32
|
+
}),
|
|
33
|
+
'validate-only': Flags.boolean({
|
|
34
|
+
default: false,
|
|
35
|
+
hidden: true,
|
|
36
|
+
description: 'Execute test but do not prompt for deploy/save actions',
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
async run() {
|
|
40
|
+
const { args, flags } = await this.parse(Test);
|
|
41
|
+
if (!flags['no-brand']) {
|
|
42
|
+
this.log(theme.brand());
|
|
43
|
+
}
|
|
44
|
+
this.log(theme.header('EPHEMERAL VALIDATION'));
|
|
45
|
+
// 1. Load Credentials
|
|
46
|
+
const config = await ConfigManager.load();
|
|
47
|
+
const n8nUrl = config.n8nUrl || process.env.N8N_API_URL;
|
|
48
|
+
const n8nKey = config.n8nKey || process.env.N8N_API_KEY;
|
|
49
|
+
if (!n8nUrl || !n8nKey) {
|
|
50
|
+
this.error('Credentials missing. Configure environment via \'n8m config\'.');
|
|
51
|
+
}
|
|
52
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
53
|
+
// 1a. Fetch Valid Node Types (New)
|
|
54
|
+
let validNodeTypes = [];
|
|
55
|
+
try {
|
|
56
|
+
validNodeTypes = await client.getNodeTypes();
|
|
57
|
+
if (validNodeTypes.length > 0) {
|
|
58
|
+
this.log(theme.success(`✔ Loaded ${validNodeTypes.length} valid node types.`));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.log(theme.warn('⚠ Could not load node types. Validation/Shimming will be limited.'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
this.log(theme.warn(`⚠ Failed to fetch node types: ${e.message}`));
|
|
66
|
+
}
|
|
67
|
+
let createdWorkflowId = null;
|
|
68
|
+
const deployedDefinitions = new Map(); // TempId -> Original JSON (for patching)
|
|
69
|
+
let globalSuccess = false;
|
|
70
|
+
try {
|
|
71
|
+
let workflowData;
|
|
72
|
+
let workflowName = 'Untitled';
|
|
73
|
+
let rootRealTargetId = undefined;
|
|
74
|
+
const dependencyMap = new Map();
|
|
75
|
+
const visited = new Set();
|
|
76
|
+
const resolutionMap = new Map();
|
|
77
|
+
let workflowChoices = [];
|
|
78
|
+
const fetchDependencies = async (id, contextNodeName = 'Unknown') => {
|
|
79
|
+
const realId = resolutionMap.get(id) || id;
|
|
80
|
+
if (visited.has(realId))
|
|
81
|
+
return;
|
|
82
|
+
visited.add(realId);
|
|
83
|
+
let wf;
|
|
84
|
+
// 1. Check local filesystem FIRST (prioritize local over instance)
|
|
85
|
+
let localPath = '';
|
|
86
|
+
const searchDirs = [
|
|
87
|
+
...(args.workflow ? [path.dirname(args.workflow)] : []),
|
|
88
|
+
path.join(process.cwd(), 'workflows'),
|
|
89
|
+
process.cwd()
|
|
90
|
+
];
|
|
91
|
+
const sanitized = id.replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
92
|
+
const candidates = [];
|
|
93
|
+
for (const dir of searchDirs) {
|
|
94
|
+
candidates.push(path.join(dir, `${id}.json`));
|
|
95
|
+
candidates.push(path.join(dir, `${sanitized}.json`));
|
|
96
|
+
candidates.push(path.join(dir, `${id.replace(/\s+/g, '-')}.json`));
|
|
97
|
+
}
|
|
98
|
+
for (const candidate of candidates) {
|
|
99
|
+
if (existsSync(candidate)) {
|
|
100
|
+
localPath = candidate;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (localPath) {
|
|
105
|
+
const content = await fs.readFile(localPath, 'utf-8');
|
|
106
|
+
wf = JSON.parse(content);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Case-insensitive search in workflows/
|
|
110
|
+
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
111
|
+
if (existsSync(workflowsDir)) {
|
|
112
|
+
const files = await fs.readdir(workflowsDir);
|
|
113
|
+
const match = files.find(f => f.toLowerCase() === `${sanitized}.json` || f.toLowerCase() === `${id.toLowerCase()}.json`);
|
|
114
|
+
if (match) {
|
|
115
|
+
localPath = path.join(workflowsDir, match);
|
|
116
|
+
const content = await fs.readFile(localPath, 'utf-8');
|
|
117
|
+
wf = JSON.parse(content);
|
|
118
|
+
}
|
|
119
|
+
else if (id.toUpperCase().includes('SUBWORKFLOW') || id.toUpperCase().includes('ID')) {
|
|
120
|
+
// FUZZY MATCH: If it's a generic placeholder, look for ANY newly created workflow in the same dir
|
|
121
|
+
const dir = args.workflow ? path.dirname(args.workflow) : workflowsDir;
|
|
122
|
+
const dirFiles = await fs.readdir(dir);
|
|
123
|
+
const jsonFiles = dirFiles.filter(f => f.endsWith('.json') && !f.includes(path.basename(args.workflow || '')));
|
|
124
|
+
if (jsonFiles.length === 1) {
|
|
125
|
+
localPath = path.join(dir, jsonFiles[0]);
|
|
126
|
+
const content = await fs.readFile(localPath, 'utf-8');
|
|
127
|
+
wf = JSON.parse(content);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// 2. If not local, try fetching from instance
|
|
133
|
+
if (!wf) {
|
|
134
|
+
try {
|
|
135
|
+
wf = await client.getWorkflow(realId);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
this.log(theme.warn(`Dependency ${theme.value(id)} referenced in node [${contextNodeName}] could not be found.`));
|
|
139
|
+
if (workflowChoices.length === 0) {
|
|
140
|
+
const workflowsList = await client.getWorkflows();
|
|
141
|
+
workflowChoices = workflowsList.map(w => ({ name: w.name, value: w.id }));
|
|
142
|
+
}
|
|
143
|
+
const { resolvedId } = await inquirer.prompt([{
|
|
144
|
+
type: 'select',
|
|
145
|
+
name: 'resolvedId',
|
|
146
|
+
message: `Select replacement for missing dependency ${id}:`,
|
|
147
|
+
choices: workflowChoices,
|
|
148
|
+
pageSize: 15
|
|
149
|
+
}]);
|
|
150
|
+
resolutionMap.set(id, resolvedId);
|
|
151
|
+
await fetchDependencies(resolvedId, contextNodeName);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
dependencyMap.set(realId, { name: wf.name, data: wf });
|
|
156
|
+
const nodes = wf.nodes || [];
|
|
157
|
+
for (const node of nodes) {
|
|
158
|
+
if (node.type === 'n8n-nodes-base.executeWorkflow') {
|
|
159
|
+
const subId = node.parameters?.workflowId;
|
|
160
|
+
if (subId && typeof subId === 'string' && !subId.startsWith('=')) {
|
|
161
|
+
await fetchDependencies(subId, node.name);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
if (args.workflow) {
|
|
167
|
+
if (!args.workflow.endsWith('.json'))
|
|
168
|
+
this.error('Local JSON path required.');
|
|
169
|
+
const content = await fs.readFile(args.workflow, 'utf-8');
|
|
170
|
+
workflowData = JSON.parse(content);
|
|
171
|
+
workflowName = workflowData.name || 'Untitled';
|
|
172
|
+
if (workflowData.id) {
|
|
173
|
+
rootRealTargetId = workflowData.id;
|
|
174
|
+
}
|
|
175
|
+
const nodes = workflowData.nodes || [];
|
|
176
|
+
for (const node of nodes) {
|
|
177
|
+
if (node.type === 'n8n-nodes-base.executeWorkflow') {
|
|
178
|
+
const subId = node.parameters?.workflowId;
|
|
179
|
+
if (subId && typeof subId === 'string' && !subId.startsWith('=')) {
|
|
180
|
+
await fetchDependencies(subId, node.name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
this.log(theme.info('Searching for local and remote workflows...'));
|
|
187
|
+
const localChoices = [];
|
|
188
|
+
const workflowsDir = path.join(process.cwd(), 'workflows');
|
|
189
|
+
const searchDirs = [workflowsDir, process.cwd()];
|
|
190
|
+
for (const dir of searchDirs) {
|
|
191
|
+
if (existsSync(dir)) {
|
|
192
|
+
const files = await fs.readdir(dir);
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
if (file.endsWith('.json')) {
|
|
195
|
+
localChoices.push({
|
|
196
|
+
name: `${theme.value('[LOCAL]')} ${file}`,
|
|
197
|
+
value: { type: 'local', path: path.join(dir, file) }
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const remoteWorkflows = await client.getWorkflows();
|
|
204
|
+
const remoteChoices = remoteWorkflows
|
|
205
|
+
.filter(w => !w.name.startsWith('[TEST'))
|
|
206
|
+
.map(w => ({
|
|
207
|
+
name: `${theme.info('[n8n]')} ${w.name} (${w.id}) ${w.active ? '[Active]' : ''}`,
|
|
208
|
+
value: { type: 'remote', id: w.id }
|
|
209
|
+
}));
|
|
210
|
+
const choices = [
|
|
211
|
+
...(localChoices.length > 0 ? [new inquirer.Separator('--- Local Files ---'), ...localChoices] : []),
|
|
212
|
+
...(remoteChoices.length > 0 ? [new inquirer.Separator('--- n8n Instance ---'), ...remoteChoices] : []),
|
|
213
|
+
];
|
|
214
|
+
if (choices.length === 0)
|
|
215
|
+
this.error('No workflows found locally or on n8n instance.');
|
|
216
|
+
const { selection } = await inquirer.prompt([{
|
|
217
|
+
type: 'select',
|
|
218
|
+
name: 'selection',
|
|
219
|
+
message: 'Select a workflow to test:',
|
|
220
|
+
choices,
|
|
221
|
+
pageSize: 15
|
|
222
|
+
}]);
|
|
223
|
+
if (selection.type === 'local') {
|
|
224
|
+
this.log(theme.agent(`Initializing virtual orchestrator for ${theme.value(selection.path)}`));
|
|
225
|
+
const content = await fs.readFile(selection.path, 'utf-8');
|
|
226
|
+
workflowData = JSON.parse(content);
|
|
227
|
+
workflowName = workflowData.name || 'Untitled';
|
|
228
|
+
const nodes = workflowData.nodes || [];
|
|
229
|
+
for (const node of nodes) {
|
|
230
|
+
if (node.type === 'n8n-nodes-base.executeWorkflow') {
|
|
231
|
+
const subId = node.parameters?.workflowId;
|
|
232
|
+
if (subId && typeof subId === 'string' && !subId.startsWith('=')) {
|
|
233
|
+
await fetchDependencies(subId, node.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await fetchDependencies(selection.id, 'ROOT');
|
|
240
|
+
const rootRealId = resolutionMap.get(selection.id) || selection.id;
|
|
241
|
+
const rootInfo = dependencyMap.get(rootRealId);
|
|
242
|
+
if (rootInfo) {
|
|
243
|
+
workflowName = rootInfo.name;
|
|
244
|
+
workflowData = rootInfo.data;
|
|
245
|
+
dependencyMap.delete(rootRealId);
|
|
246
|
+
}
|
|
247
|
+
rootRealTargetId = rootRealId;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// --- 3. Deploy Dependencies Ephemerally ---
|
|
251
|
+
const remappedIds = new Map();
|
|
252
|
+
if (dependencyMap.size > 0) {
|
|
253
|
+
// this.log(theme.subHeader('DEPENDENCY LINKING'));
|
|
254
|
+
// this.log(theme.info(`Found ${dependencyMap.size} dependencies. Deploying ephemeral copies...`));
|
|
255
|
+
for (const [originalId, info] of dependencyMap.entries()) {
|
|
256
|
+
try {
|
|
257
|
+
const depName = `[n8m:test:dep] ${info.name}`;
|
|
258
|
+
// Strict sanitize for n8n API which is picky about extra fields
|
|
259
|
+
// Strict sanitize for n8n API which is picky about extra fields
|
|
260
|
+
// 'meta' often contains templateId/instanceId which are rejected on create
|
|
261
|
+
// 'staticData', 'pinData', and 'tags' can also trigger "additional properties" on strict/older APIs
|
|
262
|
+
const allowedKeys = ['name', 'nodes', 'connections', 'settings'];
|
|
263
|
+
const depData = {};
|
|
264
|
+
for (const key of allowedKeys) {
|
|
265
|
+
if (info.data[key] !== undefined) {
|
|
266
|
+
depData[key] = info.data[key];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Ensure settings is clean
|
|
270
|
+
if (depData.settings) {
|
|
271
|
+
// Strictly allow only safe settings
|
|
272
|
+
const safeSettings = ['saveManualExecutions', 'callerPolicy', 'errorWorkflow', 'timezone', 'saveExecutionProgress', 'executionOrder'];
|
|
273
|
+
const cleanSettings = {};
|
|
274
|
+
for (const k of safeSettings) {
|
|
275
|
+
if (depData.settings[k] !== undefined)
|
|
276
|
+
cleanSettings[k] = depData.settings[k];
|
|
277
|
+
}
|
|
278
|
+
depData.settings = cleanSettings;
|
|
279
|
+
}
|
|
280
|
+
// Ensure settings exists
|
|
281
|
+
if (!depData.settings) {
|
|
282
|
+
depData.settings = {};
|
|
283
|
+
}
|
|
284
|
+
// CRITICAL: Sub-workflows often don't have triggers, preventing activation.
|
|
285
|
+
// But n8n requires referenced workflows to be "published" (active) in some contexts,
|
|
286
|
+
// or at least we want them active to be safe.
|
|
287
|
+
// So we INJECT a dummy trigger if one is missing, to satisfy the activation requirement.
|
|
288
|
+
const hasTrigger = (depData.nodes || []).some((n) => n.type.includes('Trigger') && !n.type.includes('executeWorkflowTrigger'));
|
|
289
|
+
if (!hasTrigger) {
|
|
290
|
+
// Use the client's helper to inject a shim trigger
|
|
291
|
+
// We need to cast client to any or ensure the method is public (it is)
|
|
292
|
+
const shimmed = client.injectManualTrigger(depData);
|
|
293
|
+
depData.nodes = shimmed.nodes;
|
|
294
|
+
depData.connections = shimmed.connections;
|
|
295
|
+
}
|
|
296
|
+
depData.name = depName;
|
|
297
|
+
let result;
|
|
298
|
+
try {
|
|
299
|
+
result = await client.createWorkflow(depName, depData);
|
|
300
|
+
}
|
|
301
|
+
catch (createErr) {
|
|
302
|
+
if (createErr.message.includes('additional properties')) {
|
|
303
|
+
this.log(theme.warn(` ⚠ Strict validation error. Retrying with minimal payload...`));
|
|
304
|
+
// Fallback: Drop settings entirely if it fails
|
|
305
|
+
delete depData.settings;
|
|
306
|
+
result = await client.createWorkflow(depName, depData);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
throw createErr;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// this.log(theme.success(`✔ Linked dependency: ${theme.value(info.name)} -> ${result.id}`));
|
|
313
|
+
// ACTIVATE the dependency so it can be called
|
|
314
|
+
try {
|
|
315
|
+
await client.activateWorkflow(result.id);
|
|
316
|
+
// this.log(theme.info(` └─ Active`));
|
|
317
|
+
}
|
|
318
|
+
catch (actErr) {
|
|
319
|
+
this.log(theme.warn(` ⚠ Could not activate dependency: ${actErr.message}`));
|
|
320
|
+
}
|
|
321
|
+
remappedIds.set(originalId, result.id);
|
|
322
|
+
// Track for cleanup
|
|
323
|
+
if (!this.createdWorkflowIds)
|
|
324
|
+
this.createdWorkflowIds = [];
|
|
325
|
+
this.createdWorkflowIds.push(result.id);
|
|
326
|
+
// Also handle the "Resolved" ID if it was different
|
|
327
|
+
// (e.g. valid-id in node -> resolved-id in file)
|
|
328
|
+
// The dependencyMap key *is* the ID from the node (or resolution map), so we should be good.
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
this.log(theme.warn(`⚠ Failed to deploy dependency ${info.name}: ${e.message}`));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// --- 4. Patch Root Workflow with New IDs ---
|
|
336
|
+
if (remappedIds.size > 0) {
|
|
337
|
+
const patchNodes = (nodes) => {
|
|
338
|
+
for (const node of nodes) {
|
|
339
|
+
if (node.type === 'n8n-nodes-base.executeWorkflow') {
|
|
340
|
+
const subId = node.parameters?.workflowId;
|
|
341
|
+
const realId = resolutionMap.get(subId) || subId;
|
|
342
|
+
if (subId && typeof subId === 'string' && remappedIds.has(realId)) {
|
|
343
|
+
node.parameters.workflowId = remappedIds.get(realId);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
if (workflowData.nodes) {
|
|
349
|
+
patchNodes(workflowData.nodes);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// --- GLOBAL REPAIR LOOP (Structural + Logical) ---
|
|
353
|
+
// --- AGENTIC WORKFLOW EXECUTION ---
|
|
354
|
+
this.log(theme.subHeader('AGENTIC VALIDATION'));
|
|
355
|
+
this.log(theme.agent("Initializing Agentic Workflow to validate/repair this workflow..."));
|
|
356
|
+
const goal = `Validate and fix the workflow named "${workflowName}"`;
|
|
357
|
+
const initialState = {
|
|
358
|
+
userGoal: goal,
|
|
359
|
+
messages: [],
|
|
360
|
+
validationErrors: [],
|
|
361
|
+
workflowJson: workflowData,
|
|
362
|
+
availableNodeTypes: validNodeTypes
|
|
363
|
+
};
|
|
364
|
+
// We need to route the graph logger to our CLI logger if possible, or just let it print to stdout
|
|
365
|
+
// The graph uses console.log currently, which is fine.
|
|
366
|
+
// Run the graph
|
|
367
|
+
// Note: We need to cast to any because TeamState might have stricter typing than what we pass
|
|
368
|
+
const ephemeralThreadId = `test-${Date.now()}`;
|
|
369
|
+
let result = await runAgenticWorkflow(goal, initialState, ephemeralThreadId);
|
|
370
|
+
// HITL Handling for Test Command
|
|
371
|
+
// Check if paused
|
|
372
|
+
const snapshot = await graph.getState({ configurable: { thread_id: ephemeralThreadId } });
|
|
373
|
+
if (snapshot.next && snapshot.next.length > 0) {
|
|
374
|
+
if (flags.headless) {
|
|
375
|
+
this.log(theme.info("Headless mode active. Auto-resuming..."));
|
|
376
|
+
result = await resumeAgenticWorkflow(ephemeralThreadId);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const { resume } = await inquirer.prompt([{
|
|
380
|
+
type: 'confirm',
|
|
381
|
+
name: 'resume',
|
|
382
|
+
message: 'Reviewer passed blueprint. Proceed to QA Execution?',
|
|
383
|
+
default: true
|
|
384
|
+
}]);
|
|
385
|
+
if (resume) {
|
|
386
|
+
result = await resumeAgenticWorkflow(ephemeralThreadId);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
this.log(theme.warn("Test aborted by user."));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (result.validationStatus === 'passed') {
|
|
395
|
+
globalSuccess = true;
|
|
396
|
+
// this.log(theme.success("Agentic Validation Passed!"));
|
|
397
|
+
if (result.workflowJson) {
|
|
398
|
+
// Extract the fixed/validated workflows
|
|
399
|
+
// The graph result uses the same structure as Engineer: { workflows: [...] } or just workflowJson object
|
|
400
|
+
let fixedWorkflow = result.workflowJson;
|
|
401
|
+
// If it's wrapped in a workflows array (Multi-workflow support), take the first one for now
|
|
402
|
+
if (result.workflowJson.workflows && Array.isArray(result.workflowJson.workflows)) {
|
|
403
|
+
fixedWorkflow = result.workflowJson.workflows[0];
|
|
404
|
+
}
|
|
405
|
+
const finalName = fixedWorkflow.name || workflowName;
|
|
406
|
+
deployedDefinitions.set('agentic-result', {
|
|
407
|
+
name: finalName,
|
|
408
|
+
data: fixedWorkflow,
|
|
409
|
+
type: 'root',
|
|
410
|
+
realId: rootRealTargetId
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
this.log(theme.fail("Agentic Validation Failed."));
|
|
416
|
+
if (result.validationErrors && result.validationErrors.length > 0) {
|
|
417
|
+
result.validationErrors.forEach((err) => this.log(theme.error(`Error: ${err}`)));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
const errMsg = this.cleanErrorMsg(error.message);
|
|
423
|
+
this.log(theme.fail(`Validation Failed`));
|
|
424
|
+
this.log(theme.error(errMsg));
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
if (flags['keep-on-fail'] && createdWorkflowId) {
|
|
427
|
+
this.log(theme.warn(`PRESERVATION ACTIVE: Workflow ${createdWorkflowId} persists.`));
|
|
428
|
+
createdWorkflowId = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
if (deployedDefinitions.size > 0 && globalSuccess) {
|
|
433
|
+
if (flags['validate-only']) {
|
|
434
|
+
if (args.workflow && args.workflow.endsWith('.json')) {
|
|
435
|
+
const rootDef = Array.from(deployedDefinitions.values()).find(d => d.type === 'root');
|
|
436
|
+
if (rootDef) {
|
|
437
|
+
const cleanData = this.sanitizeWorkflow(this.stripShim(rootDef.data));
|
|
438
|
+
cleanData.name = rootDef.name;
|
|
439
|
+
try {
|
|
440
|
+
await fs.writeFile(args.workflow, JSON.stringify(cleanData, null, 2));
|
|
441
|
+
this.log(theme.muted(`[Repair-Sync] Updated local file with latest repairs.`));
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
this.warn(`Failed to sync repairs: ${e.message}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
await this.handlePostTestActions(deployedDefinitions, args.workflow, client);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const allIds = [createdWorkflowId, ...(this.createdWorkflowIds || [])].filter(Boolean);
|
|
454
|
+
const uniqueIds = [...new Set(allIds)];
|
|
455
|
+
if (uniqueIds.length > 0) {
|
|
456
|
+
this.log(theme.info(`Purging ${uniqueIds.length} temporary assets...`));
|
|
457
|
+
for (const wid of uniqueIds) {
|
|
458
|
+
try {
|
|
459
|
+
if (wid)
|
|
460
|
+
await client.deleteWorkflow(wid);
|
|
461
|
+
}
|
|
462
|
+
catch { /* intentionally empty */ }
|
|
463
|
+
}
|
|
464
|
+
this.log(theme.done('Environment clean.'));
|
|
465
|
+
}
|
|
466
|
+
process.exit(0);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Extract clean error message from n8n API responses
|
|
471
|
+
*/
|
|
472
|
+
cleanErrorMsg(errMsg) {
|
|
473
|
+
if (!errMsg || typeof errMsg !== 'string')
|
|
474
|
+
return String(errMsg || 'Unknown Error');
|
|
475
|
+
try {
|
|
476
|
+
const jsonMatch = errMsg.match(/\{.*\}/);
|
|
477
|
+
if (jsonMatch) {
|
|
478
|
+
const errorObj = JSON.parse(jsonMatch[0]);
|
|
479
|
+
if (errorObj.message) {
|
|
480
|
+
return errorObj.message;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch { /* intentionally empty */ }
|
|
485
|
+
return errMsg;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Normalize error messages to catch "similar" errors (masking IDs/numbers)
|
|
489
|
+
*/
|
|
490
|
+
normalizeError(msg) {
|
|
491
|
+
const normalized = msg.toLowerCase();
|
|
492
|
+
// Group all unrecognized node type errors
|
|
493
|
+
if (normalized.includes('unrecognized node type')) {
|
|
494
|
+
return 'unrecognized node type';
|
|
495
|
+
}
|
|
496
|
+
return normalized
|
|
497
|
+
.replace(/\b[a-f0-9-]{36}\b/g, 'ID')
|
|
498
|
+
.replace(/\b[a-f0-9]{24}\b/g, 'ID')
|
|
499
|
+
.replace(/\b\d+\b/g, 'N')
|
|
500
|
+
.replace(/\s+/g, ' ')
|
|
501
|
+
.trim();
|
|
502
|
+
}
|
|
503
|
+
sanitizeWorkflow(data) {
|
|
504
|
+
// n8n API is extremely picky during UPDATE/CREATE.
|
|
505
|
+
// properties like 'meta', 'pinData', 'tags', and 'versionId' often cause 400 Bad Request
|
|
506
|
+
// 'request/body must NOT have additional properties'.
|
|
507
|
+
// We only send the core structure.
|
|
508
|
+
const allowedKeys = ['name', 'nodes', 'connections', 'settings'];
|
|
509
|
+
const sanitized = {
|
|
510
|
+
settings: data.settings || {}
|
|
511
|
+
};
|
|
512
|
+
for (const key of allowedKeys) {
|
|
513
|
+
if (data[key] !== undefined && key !== 'settings') {
|
|
514
|
+
sanitized[key] = data[key];
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return sanitized;
|
|
518
|
+
}
|
|
519
|
+
stripShim(workflowData) {
|
|
520
|
+
if (!workflowData.nodes)
|
|
521
|
+
return workflowData;
|
|
522
|
+
const nodes = workflowData.nodes.filter((n) => n.name !== 'N8M_Shim_Webhook' &&
|
|
523
|
+
n.name !== 'Shim_Flattener');
|
|
524
|
+
const connections = {};
|
|
525
|
+
for (const [nodeName, conns] of Object.entries(workflowData.connections || {})) {
|
|
526
|
+
if (nodeName === 'N8M_Shim_Webhook' || nodeName === 'Shim_Flattener')
|
|
527
|
+
continue;
|
|
528
|
+
connections[nodeName] = conns;
|
|
529
|
+
}
|
|
530
|
+
return { ...workflowData, nodes, connections };
|
|
531
|
+
}
|
|
532
|
+
async saveWorkflows(deployedDefinitions, originalPath) {
|
|
533
|
+
if (deployedDefinitions.size === 0)
|
|
534
|
+
return;
|
|
535
|
+
const { save } = await inquirer.prompt([{
|
|
536
|
+
type: 'confirm',
|
|
537
|
+
name: 'save',
|
|
538
|
+
message: 'Test passed. Save workflows locally?',
|
|
539
|
+
default: true
|
|
540
|
+
}]);
|
|
541
|
+
if (!save)
|
|
542
|
+
return;
|
|
543
|
+
for (const [, def] of deployedDefinitions.entries()) {
|
|
544
|
+
const cleanData = this.sanitizeWorkflow(this.stripShim(def.data));
|
|
545
|
+
cleanData.name = def.name;
|
|
546
|
+
const targetPath = originalPath && def.type === 'root' ? originalPath : path.join(process.cwd(), 'workflows', `${def.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`);
|
|
547
|
+
const { confirmPath } = await inquirer.prompt([{
|
|
548
|
+
type: 'input',
|
|
549
|
+
name: 'confirmPath',
|
|
550
|
+
message: `Save '${def.name}' to:`,
|
|
551
|
+
default: targetPath
|
|
552
|
+
}]);
|
|
553
|
+
try {
|
|
554
|
+
await fs.mkdir(path.dirname(confirmPath), { recursive: true });
|
|
555
|
+
await fs.writeFile(confirmPath, JSON.stringify(cleanData, null, 2));
|
|
556
|
+
this.log(theme.success(`Saved to ${confirmPath}`));
|
|
557
|
+
}
|
|
558
|
+
catch (e) {
|
|
559
|
+
this.log(theme.fail(`Failed to save: ${e.message}`));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async handlePostTestActions(deployedDefinitions, originalPath, client) {
|
|
564
|
+
if (deployedDefinitions.size === 0)
|
|
565
|
+
return;
|
|
566
|
+
const { action } = await inquirer.prompt([{
|
|
567
|
+
type: 'confirm',
|
|
568
|
+
name: 'action',
|
|
569
|
+
message: 'Test completed. Deploy changes to instance? (Y = Deploy, n = Save to file)',
|
|
570
|
+
default: true
|
|
571
|
+
}]);
|
|
572
|
+
if (action) {
|
|
573
|
+
await this.deployWorkflows(deployedDefinitions, client);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
await this.saveWorkflows(deployedDefinitions, originalPath);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async deployWorkflows(deployedDefinitions, client) {
|
|
580
|
+
for (const [, def] of deployedDefinitions.entries()) {
|
|
581
|
+
const cleanData = this.sanitizeWorkflow(this.stripShim(def.data));
|
|
582
|
+
cleanData.name = def.name;
|
|
583
|
+
if (def.realId) {
|
|
584
|
+
try {
|
|
585
|
+
this.log(theme.agent(`Deploying (Overwriting) ${theme.value(def.name)} [${def.realId}]...`));
|
|
586
|
+
await client.updateWorkflow(def.realId, cleanData);
|
|
587
|
+
this.log(theme.success(`✔ Updated ${def.name}`));
|
|
588
|
+
}
|
|
589
|
+
catch (e) {
|
|
590
|
+
const msg = e.message;
|
|
591
|
+
if (msg.includes('trigger') || msg.includes('activated')) {
|
|
592
|
+
try {
|
|
593
|
+
await client.deactivateWorkflow(def.realId);
|
|
594
|
+
await client.updateWorkflow(def.realId, cleanData);
|
|
595
|
+
this.log(theme.success(`✔ Updated ${def.name} (Deactivated)`));
|
|
596
|
+
}
|
|
597
|
+
catch (retryErr) {
|
|
598
|
+
this.log(theme.fail(`Failed to update ${def.name}: ${retryErr.message}`));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
this.log(theme.fail(`Failed to update ${def.name}: ${msg}`));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
try {
|
|
608
|
+
this.log(theme.agent(`Deploying (Creating New) ${theme.value(def.name)}...`));
|
|
609
|
+
const result = await client.createWorkflow(def.name, cleanData);
|
|
610
|
+
this.log(theme.success(`✔ Created ${def.name} [ID: ${result.id}]`));
|
|
611
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
this.log(theme.fail(`Failed to create ${def.name}: ${e.message}`));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface GenerateOptions {
|
|
2
|
+
model?: string;
|
|
3
|
+
temperature?: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class AIService {
|
|
6
|
+
private static instance;
|
|
7
|
+
private client;
|
|
8
|
+
private model;
|
|
9
|
+
private constructor();
|
|
10
|
+
static getInstance(): AIService;
|
|
11
|
+
/**
|
|
12
|
+
* Core generation method — works with any OpenAI-compatible API
|
|
13
|
+
*/
|
|
14
|
+
generateContent(prompt: string, options?: GenerateOptions): Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Generate an n8n workflow from a description
|
|
17
|
+
*/
|
|
18
|
+
generateWorkflow(description: string): Promise<any>;
|
|
19
|
+
/**
|
|
20
|
+
* Generate a Workflow Specification from a description
|
|
21
|
+
*/
|
|
22
|
+
generateSpec(description: string): Promise<any>;
|
|
23
|
+
/**
|
|
24
|
+
* Refine a Specification based on user feedback
|
|
25
|
+
*/
|
|
26
|
+
refineSpec(spec: any, feedback: string): Promise<any>;
|
|
27
|
+
/**
|
|
28
|
+
* Generate workflow JSONs from an approved Specification
|
|
29
|
+
*/
|
|
30
|
+
generateWorkflowFromSpec(spec: any): Promise<any>;
|
|
31
|
+
/**
|
|
32
|
+
* Generate mock data for a workflow execution
|
|
33
|
+
*/
|
|
34
|
+
generateMockData(context: string, previousFailures?: string[]): Promise<any>;
|
|
35
|
+
/**
|
|
36
|
+
* Diagnostic Repair: Fix a workflow based on execution error
|
|
37
|
+
*/
|
|
38
|
+
generateWorkflowFix(workflowJson: any, errorContext: string, model?: string, _useSearch?: boolean, validNodeTypes?: string[]): Promise<any>;
|
|
39
|
+
/**
|
|
40
|
+
* Auto-correct common n8n node type hallucinations
|
|
41
|
+
*/
|
|
42
|
+
private fixHallucinatedNodes;
|
|
43
|
+
/**
|
|
44
|
+
* Force-fix connection structure to prevent "object is not iterable" errors
|
|
45
|
+
*/
|
|
46
|
+
private fixN8nConnections;
|
|
47
|
+
/**
|
|
48
|
+
* Validate against real node types and shim unknown ones
|
|
49
|
+
*/
|
|
50
|
+
validateAndShim(workflow: any, validNodeTypes?: string[], explicitlyInvalid?: string[]): any;
|
|
51
|
+
}
|