@kairos-sdk/core 0.4.5 → 0.5.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 +28 -11
- package/dist/{chunk-4TS6GW6O.js → chunk-GVZKMS53.js} +27 -16
- package/dist/chunk-GVZKMS53.js.map +1 -0
- package/dist/{chunk-6CLI43FI.js → chunk-MYAGTDQ2.js} +109 -13
- package/dist/chunk-MYAGTDQ2.js.map +1 -0
- package/dist/{chunk-CR2NHLOH.js → chunk-V2IZBZGB.js} +57 -11
- package/dist/chunk-V2IZBZGB.js.map +1 -0
- package/dist/{chunk-6IXW3WCC.js → chunk-VPPWTMRJ.js} +533 -77
- package/dist/chunk-VPPWTMRJ.js.map +1 -0
- package/dist/cli.cjs +790 -145
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +54 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +740 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +5 -5
- package/dist/mcp-server.cjs +960 -347
- package/dist/mcp-server.cjs.map +1 -1
- package/dist/mcp-server.js +374 -261
- package/dist/mcp-server.js.map +1 -1
- package/dist/{reader-CpUcHhKW.d.ts → reader-B5mV20H6.d.cts} +34 -4
- package/dist/{reader-CpUcHhKW.d.cts → reader-B5mV20H6.d.ts} +34 -4
- package/dist/standalone.cjs +603 -85
- package/dist/standalone.cjs.map +1 -1
- package/dist/standalone.d.cts +2 -1
- package/dist/standalone.d.ts +2 -1
- package/dist/standalone.js +3 -3
- package/package.json +15 -5
- package/dist/chunk-4TS6GW6O.js.map +0 -1
- package/dist/chunk-6CLI43FI.js.map +0 -1
- package/dist/chunk-6IXW3WCC.js.map +0 -1
- package/dist/chunk-CR2NHLOH.js.map +0 -1
package/dist/standalone.cjs
CHANGED
|
@@ -69,7 +69,17 @@ var GuardError = class extends KairosError {
|
|
|
69
69
|
}
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
// src/errors/provider-error.ts
|
|
73
|
+
var ProviderError = class extends KairosError {
|
|
74
|
+
constructor(message, cause) {
|
|
75
|
+
super(message, cause);
|
|
76
|
+
this.name = "ProviderError";
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
72
80
|
// src/providers/n8n/provider.ts
|
|
81
|
+
var SMOKE_TEST_TIMEOUT_MS = 3e4;
|
|
82
|
+
var SMOKE_TEST_POLL_INTERVAL_MS = 1e3;
|
|
73
83
|
var N8nProvider = class {
|
|
74
84
|
constructor(client, stripper) {
|
|
75
85
|
this.client = client;
|
|
@@ -131,6 +141,71 @@ var N8nProvider = class {
|
|
|
131
141
|
async untag(workflowId, tagIds) {
|
|
132
142
|
await this.client.untagWorkflow(workflowId, tagIds);
|
|
133
143
|
}
|
|
144
|
+
async smokeTest(workflowId, workflow) {
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
const trigger = this.detectTrigger(workflow);
|
|
147
|
+
if (trigger.type === "unsupported") {
|
|
148
|
+
return { status: "not-applicable", triggerType: "not-applicable" };
|
|
149
|
+
}
|
|
150
|
+
if (trigger.type === "manual") {
|
|
151
|
+
let executionId;
|
|
152
|
+
try {
|
|
153
|
+
executionId = await this.client.triggerManual(workflowId);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { status: "error", triggerType: "manual", durationMs: Date.now() - start, error: String(err) };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const execution = await this.pollExecution(executionId);
|
|
159
|
+
const durationMs = Date.now() - start;
|
|
160
|
+
if (execution.status === "success") {
|
|
161
|
+
return { status: "passed", triggerType: "manual", executionId, durationMs };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: "failed",
|
|
165
|
+
triggerType: "manual",
|
|
166
|
+
executionId,
|
|
167
|
+
durationMs,
|
|
168
|
+
error: `Execution ended with status: ${execution.status}`
|
|
169
|
+
};
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return { status: "error", triggerType: "manual", executionId, durationMs: Date.now() - start, error: String(err) };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const statusCode = await this.client.triggerWebhookTest(trigger.path);
|
|
176
|
+
const durationMs = Date.now() - start;
|
|
177
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
178
|
+
return { status: "passed", triggerType: "webhook", durationMs };
|
|
179
|
+
}
|
|
180
|
+
return { status: "failed", triggerType: "webhook", durationMs, error: `Webhook returned HTTP ${statusCode}` };
|
|
181
|
+
} catch (err) {
|
|
182
|
+
return { status: "error", triggerType: "webhook", durationMs: Date.now() - start, error: String(err) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
detectTrigger(workflow) {
|
|
186
|
+
for (const node of workflow.nodes) {
|
|
187
|
+
if (node.type === "n8n-nodes-base.manualTrigger") return { type: "manual" };
|
|
188
|
+
if (node.type === "n8n-nodes-base.webhook") {
|
|
189
|
+
const params = node.parameters;
|
|
190
|
+
const path = typeof params?.["path"] === "string" ? params["path"] : "webhook";
|
|
191
|
+
return { type: "webhook", path };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { type: "unsupported" };
|
|
195
|
+
}
|
|
196
|
+
async pollExecution(executionId) {
|
|
197
|
+
const deadline = Date.now() + SMOKE_TEST_TIMEOUT_MS;
|
|
198
|
+
for (; ; ) {
|
|
199
|
+
const execution = await this.client.getExecution(executionId);
|
|
200
|
+
if (execution.status !== "running" && execution.status !== "waiting") {
|
|
201
|
+
return execution;
|
|
202
|
+
}
|
|
203
|
+
const remaining = deadline - Date.now();
|
|
204
|
+
if (remaining <= 0) break;
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(SMOKE_TEST_POLL_INTERVAL_MS, remaining)));
|
|
206
|
+
}
|
|
207
|
+
throw new ProviderError(`Smoke test: execution ${executionId} did not complete within ${SMOKE_TEST_TIMEOUT_MS}ms`);
|
|
208
|
+
}
|
|
134
209
|
};
|
|
135
210
|
|
|
136
211
|
// src/errors/api-error.ts
|
|
@@ -143,15 +218,18 @@ var ApiError = class extends KairosError {
|
|
|
143
218
|
statusCode;
|
|
144
219
|
};
|
|
145
220
|
|
|
146
|
-
// src/errors/provider-error.ts
|
|
147
|
-
var ProviderError = class extends KairosError {
|
|
148
|
-
constructor(message, cause) {
|
|
149
|
-
super(message, cause);
|
|
150
|
-
this.name = "ProviderError";
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
|
|
154
221
|
// src/utils/retry.ts
|
|
222
|
+
function isTransientNetworkError(err) {
|
|
223
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "ECONNABORTED"]);
|
|
224
|
+
let current = err;
|
|
225
|
+
for (let i = 0; i < 4; i++) {
|
|
226
|
+
if (current === null || typeof current !== "object") break;
|
|
227
|
+
const code = current.code;
|
|
228
|
+
if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
|
|
229
|
+
current = current.cause;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
155
233
|
async function withRetry(fn, maxAttempts, delayMs, shouldRetry) {
|
|
156
234
|
let lastError;
|
|
157
235
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
@@ -176,6 +254,7 @@ function fetchWithTimeout(url, init, timeoutMs) {
|
|
|
176
254
|
|
|
177
255
|
// src/providers/n8n/api-client.ts
|
|
178
256
|
var EXECUTION_LIMIT_CAP = 100;
|
|
257
|
+
var N8N_API_PAGE_SIZE = 250;
|
|
179
258
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
180
259
|
var RETRY_ATTEMPTS = 3;
|
|
181
260
|
var RETRY_DELAY_MS = 1e3;
|
|
@@ -184,6 +263,17 @@ var N8nApiClient = class {
|
|
|
184
263
|
this.baseUrl = baseUrl;
|
|
185
264
|
this.apiKey = apiKey;
|
|
186
265
|
this.logger = logger;
|
|
266
|
+
if (!baseUrl || typeof baseUrl !== "string") {
|
|
267
|
+
throw new GuardError("N8nApiClient: baseUrl must be a non-empty string");
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
new URL(baseUrl);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new GuardError(`N8nApiClient: baseUrl is not a valid URL: "${baseUrl}"`);
|
|
273
|
+
}
|
|
274
|
+
if (!apiKey || typeof apiKey !== "string") {
|
|
275
|
+
throw new GuardError("N8nApiClient: apiKey must be a non-empty string");
|
|
276
|
+
}
|
|
187
277
|
}
|
|
188
278
|
baseUrl;
|
|
189
279
|
apiKey;
|
|
@@ -193,7 +283,12 @@ var N8nApiClient = class {
|
|
|
193
283
|
this.logger.debug(`n8n ${method} ${path}`);
|
|
194
284
|
const isSafe = method === "GET";
|
|
195
285
|
if (!isSafe) {
|
|
196
|
-
return
|
|
286
|
+
return withRetry(
|
|
287
|
+
() => this.singleRequest(url, method, path, body),
|
|
288
|
+
2,
|
|
289
|
+
RETRY_DELAY_MS,
|
|
290
|
+
isTransientNetworkError
|
|
291
|
+
);
|
|
197
292
|
}
|
|
198
293
|
return withRetry(
|
|
199
294
|
() => this.singleRequest(url, method, path, body),
|
|
@@ -248,7 +343,7 @@ var N8nApiClient = class {
|
|
|
248
343
|
}
|
|
249
344
|
async listWorkflows() {
|
|
250
345
|
const all = [];
|
|
251
|
-
let path =
|
|
346
|
+
let path = `/workflows?limit=${N8N_API_PAGE_SIZE}`;
|
|
252
347
|
for (; ; ) {
|
|
253
348
|
const response = await this.request("GET", path);
|
|
254
349
|
for (const w of response.data) {
|
|
@@ -262,7 +357,7 @@ var N8nApiClient = class {
|
|
|
262
357
|
});
|
|
263
358
|
}
|
|
264
359
|
if (!response.nextCursor) break;
|
|
265
|
-
path = `/workflows?limit
|
|
360
|
+
path = `/workflows?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
|
|
266
361
|
}
|
|
267
362
|
return all;
|
|
268
363
|
}
|
|
@@ -292,14 +387,14 @@ var N8nApiClient = class {
|
|
|
292
387
|
}
|
|
293
388
|
async listTags() {
|
|
294
389
|
const all = [];
|
|
295
|
-
let path =
|
|
390
|
+
let path = `/tags?limit=${N8N_API_PAGE_SIZE}`;
|
|
296
391
|
for (; ; ) {
|
|
297
392
|
const response = await this.request("GET", path);
|
|
298
393
|
for (const t of response.data) {
|
|
299
394
|
all.push({ id: t.id, name: t.name });
|
|
300
395
|
}
|
|
301
396
|
if (!response.nextCursor) break;
|
|
302
|
-
path = `/tags?limit
|
|
397
|
+
path = `/tags?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
|
|
303
398
|
}
|
|
304
399
|
return all;
|
|
305
400
|
}
|
|
@@ -323,6 +418,32 @@ var N8nApiClient = class {
|
|
|
323
418
|
return [];
|
|
324
419
|
}
|
|
325
420
|
}
|
|
421
|
+
async triggerManual(workflowId) {
|
|
422
|
+
const raw = await this.request("POST", `/workflows/${workflowId}/run`);
|
|
423
|
+
const inner = raw["data"];
|
|
424
|
+
const execId = inner?.["executionId"] ?? raw["executionId"];
|
|
425
|
+
if (execId === void 0 || execId === null) {
|
|
426
|
+
throw new ProviderError(
|
|
427
|
+
`n8n trigger response missing executionId \u2014 got: ${JSON.stringify(raw)}`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return String(execId);
|
|
431
|
+
}
|
|
432
|
+
async triggerWebhookTest(path) {
|
|
433
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
434
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}/webhook-test${cleanPath}`;
|
|
435
|
+
this.logger.debug(`n8n POST webhook-test ${cleanPath}`);
|
|
436
|
+
try {
|
|
437
|
+
const response = await fetchWithTimeout(
|
|
438
|
+
url,
|
|
439
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) },
|
|
440
|
+
REQUEST_TIMEOUT_MS
|
|
441
|
+
);
|
|
442
|
+
return response.status;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
throw new ProviderError(`Webhook test request failed for path "${path}"`, err);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
326
447
|
mapExecution(e) {
|
|
327
448
|
return {
|
|
328
449
|
id: e.id,
|
|
@@ -372,7 +493,13 @@ var N8nFieldStripper = class {
|
|
|
372
493
|
|
|
373
494
|
// src/utils/uuid.ts
|
|
374
495
|
function generateUUID() {
|
|
375
|
-
|
|
496
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
497
|
+
return crypto.randomUUID();
|
|
498
|
+
}
|
|
499
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
500
|
+
const r = Math.random() * 16 | 0;
|
|
501
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
502
|
+
});
|
|
376
503
|
}
|
|
377
504
|
|
|
378
505
|
// src/library/null-library.ts
|
|
@@ -412,12 +539,32 @@ function scoreToMode(score) {
|
|
|
412
539
|
}
|
|
413
540
|
|
|
414
541
|
// src/library/scorer.ts
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
542
|
+
function loadWeights() {
|
|
543
|
+
const raw = {
|
|
544
|
+
tfidf: parseFloat(process.env["KAIROS_WEIGHT_TFIDF"] ?? ""),
|
|
545
|
+
nodeFingerprint: parseFloat(process.env["KAIROS_WEIGHT_JACCARD"] ?? ""),
|
|
546
|
+
outcome: parseFloat(process.env["KAIROS_WEIGHT_OUTCOME"] ?? ""),
|
|
547
|
+
deploy: parseFloat(process.env["KAIROS_WEIGHT_DEPLOY"] ?? "")
|
|
548
|
+
};
|
|
549
|
+
const defaults = { tfidf: 0.35, nodeFingerprint: 0.3, outcome: 0.2, deploy: 0.15 };
|
|
550
|
+
const anySet = Object.values(raw).some((v) => !isNaN(v) && v >= 0);
|
|
551
|
+
if (!anySet) return defaults;
|
|
552
|
+
const w = {
|
|
553
|
+
tfidf: !isNaN(raw.tfidf) && raw.tfidf >= 0 ? raw.tfidf : defaults.tfidf,
|
|
554
|
+
nodeFingerprint: !isNaN(raw.nodeFingerprint) && raw.nodeFingerprint >= 0 ? raw.nodeFingerprint : defaults.nodeFingerprint,
|
|
555
|
+
outcome: !isNaN(raw.outcome) && raw.outcome >= 0 ? raw.outcome : defaults.outcome,
|
|
556
|
+
deploy: !isNaN(raw.deploy) && raw.deploy >= 0 ? raw.deploy : defaults.deploy
|
|
557
|
+
};
|
|
558
|
+
const total = w.tfidf + w.nodeFingerprint + w.outcome + w.deploy;
|
|
559
|
+
if (total <= 0) return defaults;
|
|
560
|
+
return {
|
|
561
|
+
tfidf: w.tfidf / total,
|
|
562
|
+
nodeFingerprint: w.nodeFingerprint / total,
|
|
563
|
+
outcome: w.outcome / total,
|
|
564
|
+
deploy: w.deploy / total
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
var WEIGHTS = loadWeights();
|
|
421
568
|
var NODE_KEYWORDS = {
|
|
422
569
|
slack: ["slack", "slackApi"],
|
|
423
570
|
email: ["gmail", "sendEmail", "emailSend", "emailReadImap"],
|
|
@@ -602,6 +749,8 @@ function clusterWorkflows(workflows) {
|
|
|
602
749
|
}
|
|
603
750
|
return clusters.sort((a, b) => b.members.length - a.members.length);
|
|
604
751
|
}
|
|
752
|
+
var NOVELTY_BOOST = 0.05;
|
|
753
|
+
var NOVELTY_PENALTY = 0.03;
|
|
605
754
|
function rerank(candidates, clusters) {
|
|
606
755
|
const clusterMap = /* @__PURE__ */ new Map();
|
|
607
756
|
for (const cluster of clusters) {
|
|
@@ -609,7 +758,7 @@ function rerank(candidates, clusters) {
|
|
|
609
758
|
clusterMap.set(member.id, cluster);
|
|
610
759
|
}
|
|
611
760
|
}
|
|
612
|
-
|
|
761
|
+
const pass1 = candidates.map((c) => {
|
|
613
762
|
const cluster = clusterMap.get(c.workflow.id);
|
|
614
763
|
let boost = 0;
|
|
615
764
|
if (cluster && cluster.avgFirstTryPassRate > 0) {
|
|
@@ -621,7 +770,25 @@ function rerank(candidates, clusters) {
|
|
|
621
770
|
return {
|
|
622
771
|
workflow: c.workflow,
|
|
623
772
|
score: Math.max(0, Math.min(1, c.score + boost)),
|
|
624
|
-
|
|
773
|
+
cluster
|
|
774
|
+
};
|
|
775
|
+
}).sort((a, b) => b.score - a.score);
|
|
776
|
+
const seenFingerprints = /* @__PURE__ */ new Set();
|
|
777
|
+
return pass1.map((c) => {
|
|
778
|
+
const fpKey = c.cluster ? fingerprintKey(c.cluster.fingerprint) : null;
|
|
779
|
+
let noveltyAdjust = 0;
|
|
780
|
+
if (fpKey !== null) {
|
|
781
|
+
if (!seenFingerprints.has(fpKey)) {
|
|
782
|
+
seenFingerprints.add(fpKey);
|
|
783
|
+
noveltyAdjust = NOVELTY_BOOST;
|
|
784
|
+
} else {
|
|
785
|
+
noveltyAdjust = -NOVELTY_PENALTY;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
workflow: c.workflow,
|
|
790
|
+
score: Math.max(0, Math.min(1, c.score + noveltyAdjust)),
|
|
791
|
+
...c.cluster ? { clusterPattern: c.cluster.pattern } : {}
|
|
625
792
|
};
|
|
626
793
|
}).sort((a, b) => b.score - a.score);
|
|
627
794
|
}
|
|
@@ -638,7 +805,11 @@ function buildSearchCorpus(w) {
|
|
|
638
805
|
});
|
|
639
806
|
return `${w.description} ${w.workflow.name} ${w.tags.join(" ")} ${nodeTokens.join(" ")}`;
|
|
640
807
|
}
|
|
641
|
-
var
|
|
808
|
+
var _rawSize = parseInt(process.env["KAIROS_LIBRARY_SIZE"] ?? "500", 10);
|
|
809
|
+
var MAX_LIBRARY_SIZE = Number.isFinite(_rawSize) && _rawSize >= 10 ? _rawSize : 500;
|
|
810
|
+
function evictionScore(m) {
|
|
811
|
+
return (m.deployCount ?? 0) * 3 + (m.timesRetrieved ?? 0) + (m.outcomeStats?.totalUses ?? 0);
|
|
812
|
+
}
|
|
642
813
|
function isValidMeta(item) {
|
|
643
814
|
return typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.description === "string" && typeof item.workflowName === "string" && Array.isArray(item.cachedNodeTypes);
|
|
644
815
|
}
|
|
@@ -686,6 +857,7 @@ var FileLibrary = class {
|
|
|
686
857
|
} catch {
|
|
687
858
|
this.meta = [];
|
|
688
859
|
}
|
|
860
|
+
await this.scanForOrphansAndCleanup();
|
|
689
861
|
} else {
|
|
690
862
|
try {
|
|
691
863
|
const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
@@ -700,6 +872,31 @@ var FileLibrary = class {
|
|
|
700
872
|
await (0, import_promises.mkdir)(this.workflowsDir, { recursive: true });
|
|
701
873
|
}
|
|
702
874
|
}
|
|
875
|
+
async scanForOrphansAndCleanup() {
|
|
876
|
+
let entries;
|
|
877
|
+
try {
|
|
878
|
+
entries = await (0, import_promises.readdir)(this.workflowsDir);
|
|
879
|
+
} catch {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const indexedIds = new Set(this.meta.map((m) => m.id));
|
|
883
|
+
const orphanIds = [];
|
|
884
|
+
for (const filename of entries) {
|
|
885
|
+
if (filename.endsWith(".tmp")) {
|
|
886
|
+
await (0, import_promises.unlink)((0, import_node_path.join)(this.workflowsDir, filename)).catch(() => {
|
|
887
|
+
});
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!filename.endsWith(".json")) continue;
|
|
891
|
+
const id = filename.slice(0, -5);
|
|
892
|
+
if (!indexedIds.has(id)) {
|
|
893
|
+
orphanIds.push(id);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (orphanIds.length > 0) {
|
|
897
|
+
console.warn(`[FileLibrary] Found ${orphanIds.length} orphaned workflow file(s) not in index: ${orphanIds.join(", ")}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
703
900
|
/**
|
|
704
901
|
* One-time transparent migration from v0.4.x monolithic index.json.
|
|
705
902
|
* Splits each stored workflow into a per-file workflow JSON and a lightweight
|
|
@@ -770,10 +967,12 @@ var FileLibrary = class {
|
|
|
770
967
|
const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
|
|
771
968
|
const docCount = shells.length;
|
|
772
969
|
const idf = /* @__PURE__ */ new Map();
|
|
970
|
+
const idfCeiling = Math.log(docCount + 1) + 1;
|
|
773
971
|
const allTokens = new Set(queryTokens);
|
|
774
972
|
for (const token of allTokens) {
|
|
775
973
|
const docsWithToken = docTokenSets.filter((d) => d.has(token)).length;
|
|
776
|
-
|
|
974
|
+
const rawIdf = Math.log((docCount + 1) / (docsWithToken + 1)) + 1;
|
|
975
|
+
idf.set(token, rawIdf / idfCeiling);
|
|
777
976
|
}
|
|
778
977
|
const scored = hybridScore(queryTokens, description, shells, docTokenArrays, idf).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
|
|
779
978
|
const clusters = clusterWorkflows(shells);
|
|
@@ -799,6 +998,27 @@ var FileLibrary = class {
|
|
|
799
998
|
return results.filter((r) => r !== null);
|
|
800
999
|
}
|
|
801
1000
|
async save(workflow, metadata) {
|
|
1001
|
+
const existingByN8nId = metadata.n8nWorkflowId ? this.meta.find((m) => m.n8nWorkflowId === metadata.n8nWorkflowId) : void 0;
|
|
1002
|
+
const normalizedDesc = metadata.description.trim().toLowerCase();
|
|
1003
|
+
const existing = existingByN8nId ?? this.meta.find((m) => m.description.trim().toLowerCase() === normalizedDesc);
|
|
1004
|
+
if (existing) {
|
|
1005
|
+
existing.description = metadata.description;
|
|
1006
|
+
existing.workflowName = workflow.name;
|
|
1007
|
+
existing.cachedNodeTypes = workflow.nodes.map((n) => n.type);
|
|
1008
|
+
if (metadata.n8nWorkflowId) existing.n8nWorkflowId = metadata.n8nWorkflowId;
|
|
1009
|
+
if (metadata.generationAttempts != null) {
|
|
1010
|
+
existing.generationAttempts = metadata.generationAttempts;
|
|
1011
|
+
}
|
|
1012
|
+
if (metadata.failurePatterns?.length) {
|
|
1013
|
+
existing.failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
|
|
1014
|
+
}
|
|
1015
|
+
if (metadata.tags?.length) {
|
|
1016
|
+
existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...metadata.tags])];
|
|
1017
|
+
}
|
|
1018
|
+
await this.writeWorkflowFile(existing.id, workflow);
|
|
1019
|
+
await this.persist();
|
|
1020
|
+
return existing.id;
|
|
1021
|
+
}
|
|
802
1022
|
const id = generateUUID();
|
|
803
1023
|
await this.writeWorkflowFile(id, workflow);
|
|
804
1024
|
const failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
|
|
@@ -820,25 +1040,27 @@ var FileLibrary = class {
|
|
|
820
1040
|
...metadata.sourceKind ? { sourceKind: metadata.sourceKind } : {},
|
|
821
1041
|
...metadata.sourceId ? { sourceId: metadata.sourceId } : {},
|
|
822
1042
|
...metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {},
|
|
823
|
-
...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {}
|
|
1043
|
+
...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {},
|
|
1044
|
+
...metadata.n8nWorkflowId ? { n8nWorkflowId: metadata.n8nWorkflowId } : {}
|
|
824
1045
|
};
|
|
825
1046
|
this.meta.push(meta);
|
|
826
1047
|
if (this.meta.length > MAX_LIBRARY_SIZE) {
|
|
827
1048
|
this.meta.sort((a, b) => {
|
|
828
1049
|
if (a.id === id) return -1;
|
|
829
1050
|
if (b.id === id) return 1;
|
|
830
|
-
return (b
|
|
1051
|
+
return evictionScore(b) - evictionScore(a);
|
|
831
1052
|
});
|
|
832
1053
|
this.meta = this.meta.slice(0, MAX_LIBRARY_SIZE);
|
|
833
1054
|
}
|
|
834
1055
|
await this.persist();
|
|
835
1056
|
return id;
|
|
836
1057
|
}
|
|
837
|
-
async recordDeployment(id) {
|
|
1058
|
+
async recordDeployment(id, n8nWorkflowId) {
|
|
838
1059
|
const m = this.meta.find((m2) => m2.id === id);
|
|
839
1060
|
if (m) {
|
|
840
1061
|
m.deployCount++;
|
|
841
1062
|
m.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1063
|
+
if (n8nWorkflowId) m.n8nWorkflowId = n8nWorkflowId;
|
|
842
1064
|
await this.persist();
|
|
843
1065
|
}
|
|
844
1066
|
}
|
|
@@ -901,37 +1123,98 @@ var FileLibrary = class {
|
|
|
901
1123
|
}
|
|
902
1124
|
return [...map.values()];
|
|
903
1125
|
}
|
|
1126
|
+
// ── Cross-process file locking ────────────────────────────────────────────
|
|
1127
|
+
// Uses O_EXCL (exclusive create) which is atomic on POSIX and Windows NTFS.
|
|
1128
|
+
// Protects the read-modify-write cycle in persist() from concurrent writers
|
|
1129
|
+
// in separate OS processes (e.g. MCP server + CLI running simultaneously).
|
|
1130
|
+
get lockPath() {
|
|
1131
|
+
return (0, import_node_path.join)(this.dir, ".index.lock");
|
|
1132
|
+
}
|
|
1133
|
+
async acquireLock(timeoutMs = 3e3) {
|
|
1134
|
+
const deadline = Date.now() + timeoutMs;
|
|
1135
|
+
let delayMs = 10;
|
|
1136
|
+
while (true) {
|
|
1137
|
+
try {
|
|
1138
|
+
const fh = await (0, import_promises.open)(this.lockPath, "wx");
|
|
1139
|
+
await fh.writeFile(String(process.pid));
|
|
1140
|
+
await fh.close();
|
|
1141
|
+
return async () => {
|
|
1142
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
} catch {
|
|
1146
|
+
try {
|
|
1147
|
+
const content = await (0, import_promises.readFile)(this.lockPath, "utf-8");
|
|
1148
|
+
const lockPid = parseInt(content.trim(), 10);
|
|
1149
|
+
const fileStat = await (0, import_promises.stat)(this.lockPath);
|
|
1150
|
+
const ageMs = Date.now() - fileStat.mtimeMs;
|
|
1151
|
+
if (ageMs > 1e4) {
|
|
1152
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
1153
|
+
});
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
if (!isNaN(lockPid)) {
|
|
1157
|
+
try {
|
|
1158
|
+
process.kill(lockPid, 0);
|
|
1159
|
+
} catch {
|
|
1160
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
1161
|
+
});
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
} catch {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (Date.now() > deadline) {
|
|
1169
|
+
return async () => {
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1173
|
+
delayMs = Math.min(delayMs * 1.5, 200);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
904
1177
|
/**
|
|
905
1178
|
* Direct write used only during migration (before writeQueue is needed).
|
|
906
1179
|
*/
|
|
907
1180
|
async persistNow() {
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1181
|
+
const releaseLock = await this.acquireLock();
|
|
1182
|
+
try {
|
|
1183
|
+
const indexPath = (0, import_node_path.join)(this.dir, "index.json");
|
|
1184
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
1185
|
+
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
|
|
1186
|
+
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
1187
|
+
} finally {
|
|
1188
|
+
await releaseLock();
|
|
1189
|
+
}
|
|
912
1190
|
}
|
|
913
1191
|
persist() {
|
|
914
1192
|
this.writeQueue = this.writeQueue.then(async () => {
|
|
915
|
-
const
|
|
916
|
-
let onDisk = [];
|
|
1193
|
+
const releaseLock = await this.acquireLock();
|
|
917
1194
|
try {
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1195
|
+
const indexPath = (0, import_node_path.join)(this.dir, "index.json");
|
|
1196
|
+
let onDisk = [];
|
|
1197
|
+
try {
|
|
1198
|
+
const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
1199
|
+
const parsed = JSON.parse(raw);
|
|
1200
|
+
if (Array.isArray(parsed)) {
|
|
1201
|
+
onDisk = parsed.filter(isValidMeta);
|
|
1202
|
+
}
|
|
1203
|
+
} catch {
|
|
922
1204
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1205
|
+
const ourIds = new Set(this.meta.map((m) => m.id));
|
|
1206
|
+
const external = onDisk.filter((m) => !ourIds.has(m.id));
|
|
1207
|
+
let merged = [...this.meta, ...external];
|
|
1208
|
+
if (merged.length > MAX_LIBRARY_SIZE) {
|
|
1209
|
+
merged.sort((a, b) => evictionScore(b) - evictionScore(a));
|
|
1210
|
+
merged = merged.slice(0, MAX_LIBRARY_SIZE);
|
|
1211
|
+
}
|
|
1212
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
1213
|
+
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
1214
|
+
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
1215
|
+
} finally {
|
|
1216
|
+
await releaseLock();
|
|
931
1217
|
}
|
|
932
|
-
const tmpPath = `${indexPath}.tmp`;
|
|
933
|
-
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
934
|
-
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
935
1218
|
});
|
|
936
1219
|
return this.writeQueue;
|
|
937
1220
|
}
|
|
@@ -1037,6 +1320,14 @@ var NodeRegistry = class {
|
|
|
1037
1320
|
if (!def) return true;
|
|
1038
1321
|
return def.safeTypeVersions.includes(version);
|
|
1039
1322
|
}
|
|
1323
|
+
// Returns true when the version is a positive integer greater than the highest
|
|
1324
|
+
// known safe version — indicates a newer release rather than a bad value.
|
|
1325
|
+
isVersionNewer(type, version) {
|
|
1326
|
+
const def = this.byType.get(type);
|
|
1327
|
+
if (!def || def.safeTypeVersions.length === 0) return false;
|
|
1328
|
+
const max = Math.max(...def.safeTypeVersions);
|
|
1329
|
+
return Number.isInteger(version) && version > max;
|
|
1330
|
+
}
|
|
1040
1331
|
getRequiredParams(type) {
|
|
1041
1332
|
return this.byType.get(type)?.requiredParams ?? [];
|
|
1042
1333
|
}
|
|
@@ -1089,6 +1380,14 @@ var N8nValidator = class {
|
|
|
1089
1380
|
this.checkRule24(workflow, issues);
|
|
1090
1381
|
this.checkRule25(workflow, issues);
|
|
1091
1382
|
this.checkRule26(workflow, issues);
|
|
1383
|
+
this.checkRule27(workflow, issues);
|
|
1384
|
+
this.checkRule28(workflow, issues);
|
|
1385
|
+
this.checkRule29(workflow, issues);
|
|
1386
|
+
this.checkRule30(workflow, issues);
|
|
1387
|
+
this.checkRule31(workflow, issues);
|
|
1388
|
+
this.checkRule32(workflow, issues);
|
|
1389
|
+
this.checkRule33(workflow, issues);
|
|
1390
|
+
this.checkRule34(workflow, issues);
|
|
1092
1391
|
if (Array.isArray(workflow.nodes)) {
|
|
1093
1392
|
const nodeById = new Map(workflow.nodes.map((n) => [n.id, n.type]));
|
|
1094
1393
|
for (const issue of issues) {
|
|
@@ -1340,19 +1639,22 @@ var N8nValidator = class {
|
|
|
1340
1639
|
}
|
|
1341
1640
|
}
|
|
1342
1641
|
}
|
|
1343
|
-
// Rule 19 (WARN): typeVersion is within known safe range for registered node types
|
|
1642
|
+
// Rule 19 (WARN): typeVersion is within known safe range for registered node types.
|
|
1643
|
+
// In lenient mode (KAIROS_REGISTRY_STRICT != 'true'), versions higher than the known
|
|
1644
|
+
// max are allowed — they likely represent newer n8n releases Kairos hasn't catalogued yet.
|
|
1344
1645
|
checkRule19(w, issues) {
|
|
1345
1646
|
if (!Array.isArray(w.nodes)) return;
|
|
1647
|
+
const strict = process.env["KAIROS_REGISTRY_STRICT"] === "true";
|
|
1346
1648
|
for (const node of w.nodes) {
|
|
1347
1649
|
if (typeof node.type !== "string" || typeof node.typeVersion !== "number") continue;
|
|
1348
|
-
if (
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1650
|
+
if (this.registry.isVersionSafe(node.type, node.typeVersion)) continue;
|
|
1651
|
+
if (!strict && this.registry.isVersionNewer(node.type, node.typeVersion)) continue;
|
|
1652
|
+
this.warn(
|
|
1653
|
+
issues,
|
|
1654
|
+
19,
|
|
1655
|
+
`Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
|
|
1656
|
+
node.id
|
|
1657
|
+
);
|
|
1356
1658
|
}
|
|
1357
1659
|
}
|
|
1358
1660
|
// Rule 20 (WARN): cycle detection — no node should be reachable from itself
|
|
@@ -1401,6 +1703,27 @@ var N8nValidator = class {
|
|
|
1401
1703
|
}
|
|
1402
1704
|
}
|
|
1403
1705
|
}
|
|
1706
|
+
// Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
|
|
1707
|
+
checkRule21(w, issues) {
|
|
1708
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1709
|
+
const webhooksNeedingResponse = w.nodes.filter((n) => {
|
|
1710
|
+
if (!n.type.includes("webhook")) return false;
|
|
1711
|
+
const params = n.parameters;
|
|
1712
|
+
return params?.responseMode === "responseNode";
|
|
1713
|
+
});
|
|
1714
|
+
if (webhooksNeedingResponse.length === 0) return;
|
|
1715
|
+
const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
|
|
1716
|
+
if (!hasRespondNode) {
|
|
1717
|
+
for (const wh of webhooksNeedingResponse) {
|
|
1718
|
+
this.warn(
|
|
1719
|
+
issues,
|
|
1720
|
+
21,
|
|
1721
|
+
`Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
|
|
1722
|
+
wh.id
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1404
1727
|
// Rule 22 (WARN): check requiredParams from registry
|
|
1405
1728
|
checkRule22(w, issues) {
|
|
1406
1729
|
if (!Array.isArray(w.nodes)) return;
|
|
@@ -1509,23 +1832,162 @@ var N8nValidator = class {
|
|
|
1509
1832
|
walk(params);
|
|
1510
1833
|
return expressions;
|
|
1511
1834
|
}
|
|
1512
|
-
// Rule
|
|
1513
|
-
|
|
1835
|
+
// Rule 27 (WARN): httpRequest URL is a placeholder
|
|
1836
|
+
checkRule27(w, issues) {
|
|
1514
1837
|
if (!Array.isArray(w.nodes)) return;
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1838
|
+
const PLACEHOLDER_RE = [
|
|
1839
|
+
/^https?:\/\/example\.com/i,
|
|
1840
|
+
/your[-_]?(api[-_]?)?url/i,
|
|
1841
|
+
/^https?:\/\/$/,
|
|
1842
|
+
/^<.+>$/,
|
|
1843
|
+
/placeholder/i
|
|
1844
|
+
];
|
|
1845
|
+
for (const node of w.nodes) {
|
|
1846
|
+
if (node.type !== "n8n-nodes-base.httpRequest") continue;
|
|
1847
|
+
const params = node.parameters;
|
|
1848
|
+
const url = params?.["url"];
|
|
1849
|
+
if (typeof url !== "string" || url.trim() === "") continue;
|
|
1850
|
+
if (PLACEHOLDER_RE.some((re) => re.test(url.trim()))) {
|
|
1524
1851
|
this.warn(
|
|
1525
1852
|
issues,
|
|
1526
|
-
|
|
1527
|
-
`
|
|
1528
|
-
|
|
1853
|
+
27,
|
|
1854
|
+
`Node "${node.name}" httpRequest URL appears to be a placeholder: "${url}" \u2014 replace with your actual endpoint`,
|
|
1855
|
+
node.id
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
// Rule 28 (WARN): code node with empty or comment-only code
|
|
1861
|
+
checkRule28(w, issues) {
|
|
1862
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1863
|
+
for (const node of w.nodes) {
|
|
1864
|
+
if (node.type !== "n8n-nodes-base.code") continue;
|
|
1865
|
+
const params = node.parameters;
|
|
1866
|
+
const jsCode = typeof params?.["jsCode"] === "string" ? params["jsCode"] : "";
|
|
1867
|
+
const pythonCode = typeof params?.["pythonCode"] === "string" ? params["pythonCode"] : "";
|
|
1868
|
+
const code = jsCode || pythonCode;
|
|
1869
|
+
const stripped = code.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/#[^\n]*/g, "").trim();
|
|
1870
|
+
if (!stripped) {
|
|
1871
|
+
this.warn(issues, 28, `Node "${node.name}" code node has no executable code`, node.id);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
// Rule 29 (WARN): slack node message operation missing channel
|
|
1876
|
+
checkRule29(w, issues) {
|
|
1877
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1878
|
+
for (const node of w.nodes) {
|
|
1879
|
+
if (node.type !== "n8n-nodes-base.slack") continue;
|
|
1880
|
+
const params = node.parameters;
|
|
1881
|
+
const resource = params?.["resource"];
|
|
1882
|
+
const operation = params?.["operation"];
|
|
1883
|
+
const isMessageOp = resource === "message" || operation === "sendMessage" || operation === "post";
|
|
1884
|
+
if (!isMessageOp) continue;
|
|
1885
|
+
const channel = params?.["channel"] ?? params?.["channelId"];
|
|
1886
|
+
const rlValue = typeof channel === "object" && channel !== null ? channel["value"] : void 0;
|
|
1887
|
+
const isEmpty = channel === void 0 || channel === null || typeof channel === "string" && channel.trim() === "" || typeof channel === "object" && (!rlValue || typeof rlValue === "string" && rlValue.trim() === "");
|
|
1888
|
+
if (isEmpty) {
|
|
1889
|
+
this.warn(issues, 29, `Node "${node.name}" Slack message has no channel specified`, node.id);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// Rule 30 (WARN): gmail node send operation missing recipient
|
|
1894
|
+
checkRule30(w, issues) {
|
|
1895
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1896
|
+
for (const node of w.nodes) {
|
|
1897
|
+
if (node.type !== "n8n-nodes-base.gmail") continue;
|
|
1898
|
+
const params = node.parameters;
|
|
1899
|
+
const operation = params?.["operation"];
|
|
1900
|
+
if (operation !== "send") continue;
|
|
1901
|
+
const to = params?.["to"] ?? params?.["toList"];
|
|
1902
|
+
const isEmpty = to === void 0 || to === null || typeof to === "string" && to.trim() === "" || Array.isArray(to) && to.length === 0;
|
|
1903
|
+
if (isEmpty) {
|
|
1904
|
+
this.warn(issues, 30, `Node "${node.name}" gmail send has no recipient (to) specified`, node.id);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
// Rule 31 (WARN): if node with empty conditions
|
|
1909
|
+
checkRule31(w, issues) {
|
|
1910
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1911
|
+
for (const node of w.nodes) {
|
|
1912
|
+
if (node.type !== "n8n-nodes-base.if") continue;
|
|
1913
|
+
const params = node.parameters;
|
|
1914
|
+
const conditions = params?.["conditions"];
|
|
1915
|
+
if (conditions === void 0 || conditions === null) {
|
|
1916
|
+
this.warn(issues, 31, `Node "${node.name}" if node has no conditions defined`, node.id);
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (typeof conditions === "object" && !Array.isArray(conditions)) {
|
|
1920
|
+
const conds = conditions["conditions"];
|
|
1921
|
+
if (!Array.isArray(conds) || conds.length === 0) {
|
|
1922
|
+
this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
|
|
1923
|
+
}
|
|
1924
|
+
} else if (Array.isArray(conditions) && conditions.length === 0) {
|
|
1925
|
+
this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
// Rule 32 (WARN): set node with no assignments
|
|
1930
|
+
checkRule32(w, issues) {
|
|
1931
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1932
|
+
for (const node of w.nodes) {
|
|
1933
|
+
if (node.type !== "n8n-nodes-base.set") continue;
|
|
1934
|
+
const params = node.parameters;
|
|
1935
|
+
const assignmentsObj = params?.["assignments"];
|
|
1936
|
+
const assignmentsArr = assignmentsObj?.["assignments"];
|
|
1937
|
+
const valuesObj = params?.["values"];
|
|
1938
|
+
const hasV1 = valuesObj && Object.values(valuesObj).some((v) => Array.isArray(v) && v.length > 0);
|
|
1939
|
+
const hasV3 = Array.isArray(assignmentsArr) && assignmentsArr.length > 0;
|
|
1940
|
+
if (!hasV1 && !hasV3) {
|
|
1941
|
+
this.warn(
|
|
1942
|
+
issues,
|
|
1943
|
+
32,
|
|
1944
|
+
`Node "${node.name}" set node has no fields defined \u2014 it will pass data through unchanged`,
|
|
1945
|
+
node.id
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
// Rule 33 (WARN): scheduleTrigger with no schedule rules
|
|
1951
|
+
checkRule33(w, issues) {
|
|
1952
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1953
|
+
for (const node of w.nodes) {
|
|
1954
|
+
if (node.type !== "n8n-nodes-base.scheduleTrigger") continue;
|
|
1955
|
+
const params = node.parameters;
|
|
1956
|
+
const rule = params?.["rule"];
|
|
1957
|
+
const intervals = rule?.["interval"];
|
|
1958
|
+
if (!Array.isArray(intervals) || intervals.length === 0) {
|
|
1959
|
+
this.warn(issues, 33, `Node "${node.name}" scheduleTrigger has no schedule rules defined`, node.id);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
// Rule 34 (WARN): webhook path contains spaces, starts with slash, or looks like a full URL
|
|
1964
|
+
checkRule34(w, issues) {
|
|
1965
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1966
|
+
for (const node of w.nodes) {
|
|
1967
|
+
if (node.type !== "n8n-nodes-base.webhook") continue;
|
|
1968
|
+
const params = node.parameters;
|
|
1969
|
+
const path = params?.["path"];
|
|
1970
|
+
if (typeof path !== "string") continue;
|
|
1971
|
+
if (/\s/.test(path)) {
|
|
1972
|
+
this.warn(
|
|
1973
|
+
issues,
|
|
1974
|
+
34,
|
|
1975
|
+
`Node "${node.name}" webhook path contains spaces: "${path}" \u2014 use hyphens or underscores instead`,
|
|
1976
|
+
node.id
|
|
1977
|
+
);
|
|
1978
|
+
} else if (/^https?:\/\//i.test(path)) {
|
|
1979
|
+
this.warn(
|
|
1980
|
+
issues,
|
|
1981
|
+
34,
|
|
1982
|
+
`Node "${node.name}" webhook path looks like a full URL \u2014 it should be a relative path (e.g. "my-hook")`,
|
|
1983
|
+
node.id
|
|
1984
|
+
);
|
|
1985
|
+
} else if (path.startsWith("/")) {
|
|
1986
|
+
this.warn(
|
|
1987
|
+
issues,
|
|
1988
|
+
34,
|
|
1989
|
+
`Node "${node.name}" webhook path starts with "/" \u2014 n8n adds the leading slash automatically`,
|
|
1990
|
+
node.id
|
|
1529
1991
|
);
|
|
1530
1992
|
}
|
|
1531
1993
|
}
|
|
@@ -1578,6 +2040,19 @@ var SECRET_PATTERNS = [
|
|
|
1578
2040
|
/AIza[a-zA-Z0-9_-]{35}/,
|
|
1579
2041
|
/AKIA[A-Z0-9]{16}/
|
|
1580
2042
|
];
|
|
2043
|
+
var SECRET_PREFIXES = ["sk-", "ghp_", "xoxb-", "AIza", "AKIA"];
|
|
2044
|
+
function collectExpressionStrings(obj, out = []) {
|
|
2045
|
+
if (typeof obj === "string") {
|
|
2046
|
+
if (obj.includes("={{")) out.push(obj);
|
|
2047
|
+
} else if (Array.isArray(obj)) {
|
|
2048
|
+
for (const item of obj) collectExpressionStrings(item, out);
|
|
2049
|
+
} else if (obj !== null && typeof obj === "object") {
|
|
2050
|
+
for (const val of Object.values(obj)) {
|
|
2051
|
+
collectExpressionStrings(val, out);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return out;
|
|
2055
|
+
}
|
|
1581
2056
|
function assessTemplateSafety(workflow) {
|
|
1582
2057
|
const reasons = [];
|
|
1583
2058
|
let worst = "safe";
|
|
@@ -1600,6 +2075,15 @@ function assessTemplateSafety(workflow) {
|
|
|
1600
2075
|
break;
|
|
1601
2076
|
}
|
|
1602
2077
|
}
|
|
2078
|
+
const expressions = collectExpressionStrings(node.parameters);
|
|
2079
|
+
for (const expr of expressions) {
|
|
2080
|
+
for (const prefix of SECRET_PREFIXES) {
|
|
2081
|
+
if (expr.includes(prefix)) {
|
|
2082
|
+
escalate("review", `Node "${node.name}" has an expression containing credential-like prefix "${prefix}"`);
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1603
2087
|
}
|
|
1604
2088
|
return { trustLevel: worst, reasons };
|
|
1605
2089
|
}
|
|
@@ -1657,12 +2141,26 @@ var TemplateSyncer = class {
|
|
|
1657
2141
|
}
|
|
1658
2142
|
return progress;
|
|
1659
2143
|
}
|
|
2144
|
+
async fetchWithBackoff(url, maxRetries = 3) {
|
|
2145
|
+
let delayMs = DELAY_BETWEEN_FETCHES_MS;
|
|
2146
|
+
let lastResponse;
|
|
2147
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2148
|
+
lastResponse = await fetch(url);
|
|
2149
|
+
if (lastResponse.status !== 429 && lastResponse.status !== 503) return lastResponse;
|
|
2150
|
+
if (attempt === maxRetries) break;
|
|
2151
|
+
const retryAfterHeader = lastResponse.headers.get("Retry-After");
|
|
2152
|
+
const waitMs = retryAfterHeader ? parseInt(retryAfterHeader, 10) * 1e3 : delayMs * Math.pow(2, attempt);
|
|
2153
|
+
this.logger.warn(`HTTP ${lastResponse.status} from template API, retrying in ${waitMs}ms`, { url, attempt });
|
|
2154
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
2155
|
+
}
|
|
2156
|
+
return lastResponse;
|
|
2157
|
+
}
|
|
1660
2158
|
async fetchTemplateIds(max, progress) {
|
|
1661
2159
|
const ids = [];
|
|
1662
2160
|
let page = 1;
|
|
1663
2161
|
while (ids.length < max) {
|
|
1664
2162
|
const url = `${N8N_TEMPLATE_API}/search?page=${page}&rows=${PAGE_SIZE}`;
|
|
1665
|
-
const response = await
|
|
2163
|
+
const response = await this.fetchWithBackoff(url);
|
|
1666
2164
|
if (!response.ok) break;
|
|
1667
2165
|
const data = await response.json();
|
|
1668
2166
|
progress.total = Math.min(data.totalWorkflows, max);
|
|
@@ -1682,7 +2180,7 @@ var TemplateSyncer = class {
|
|
|
1682
2180
|
}
|
|
1683
2181
|
async processTemplate(id, progress) {
|
|
1684
2182
|
const url = `${N8N_TEMPLATE_API}/workflows/${id}`;
|
|
1685
|
-
const response = await
|
|
2183
|
+
const response = await this.fetchWithBackoff(url);
|
|
1686
2184
|
if (!response.ok) return;
|
|
1687
2185
|
const data = await response.json();
|
|
1688
2186
|
const templateMeta = data.workflow;
|
|
@@ -1844,19 +2342,20 @@ var TelemetryReader = class {
|
|
|
1844
2342
|
}
|
|
1845
2343
|
const events = await this.readRecentEvents(days);
|
|
1846
2344
|
const buildSessions = new Set(
|
|
1847
|
-
events.filter((e) => e.eventType === "build_complete").map((e) => e.sessionId)
|
|
2345
|
+
events.filter((e) => e.eventType === "build_complete").map((e) => e.runId ?? e.sessionId)
|
|
1848
2346
|
);
|
|
1849
2347
|
const MIN_BUILDS_FOR_RATES = 3;
|
|
1850
2348
|
if (buildSessions.size < MIN_BUILDS_FOR_RATES) return [];
|
|
1851
2349
|
const ruleSessions = /* @__PURE__ */ new Map();
|
|
1852
2350
|
for (const event of events) {
|
|
1853
2351
|
if (event.eventType !== "generation_attempt") continue;
|
|
1854
|
-
|
|
2352
|
+
const eventKey = event.runId ?? event.sessionId;
|
|
2353
|
+
if (!buildSessions.has(eventKey)) continue;
|
|
1855
2354
|
const data = event.data;
|
|
1856
2355
|
if (data.validationPassed || !data.issues) continue;
|
|
1857
2356
|
for (const issue of data.issues) {
|
|
1858
2357
|
const entry = ruleSessions.get(issue.rule) ?? { sessions: /* @__PURE__ */ new Set(), messages: /* @__PURE__ */ new Map() };
|
|
1859
|
-
entry.sessions.add(
|
|
2358
|
+
entry.sessions.add(eventKey);
|
|
1860
2359
|
entry.messages.set(issue.message, (entry.messages.get(issue.message) ?? 0) + 1);
|
|
1861
2360
|
ruleSessions.set(issue.rule, entry);
|
|
1862
2361
|
}
|
|
@@ -1895,7 +2394,7 @@ var import_node_path5 = require("path");
|
|
|
1895
2394
|
var import_node_os4 = require("os");
|
|
1896
2395
|
|
|
1897
2396
|
// src/validation/rule-metadata.ts
|
|
1898
|
-
var VALIDATOR_RULE_IDS = Array.from({ length:
|
|
2397
|
+
var VALIDATOR_RULE_IDS = Array.from({ length: 34 }, (_, i) => i + 1);
|
|
1899
2398
|
var RULE_PIPELINE_STAGES = {
|
|
1900
2399
|
1: "node_generation",
|
|
1901
2400
|
2: "node_generation",
|
|
@@ -1922,7 +2421,15 @@ var RULE_PIPELINE_STAGES = {
|
|
|
1922
2421
|
23: "node_generation",
|
|
1923
2422
|
24: "expression_syntax",
|
|
1924
2423
|
25: "expression_syntax",
|
|
1925
|
-
26: "expression_syntax"
|
|
2424
|
+
26: "expression_syntax",
|
|
2425
|
+
27: "node_generation",
|
|
2426
|
+
28: "node_generation",
|
|
2427
|
+
29: "node_generation",
|
|
2428
|
+
30: "node_generation",
|
|
2429
|
+
31: "node_generation",
|
|
2430
|
+
32: "node_generation",
|
|
2431
|
+
33: "node_generation",
|
|
2432
|
+
34: "node_generation"
|
|
1926
2433
|
};
|
|
1927
2434
|
var RULE_MITIGATIONS = {
|
|
1928
2435
|
1: "Provide a non-empty workflow name string",
|
|
@@ -1950,7 +2457,15 @@ var RULE_MITIGATIONS = {
|
|
|
1950
2457
|
23: "Use node types that exist in the n8n registry \u2014 check with kairos_sync",
|
|
1951
2458
|
24: 'Use modern accessor syntax: $("NodeName").item.json.field instead of deprecated $node["NodeName"].json.field',
|
|
1952
2459
|
25: "Access item fields directly with $json.field \u2014 n8n flattens items automatically, do not use $json.items[0]",
|
|
1953
|
-
26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime'
|
|
2460
|
+
26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime',
|
|
2461
|
+
27: 'Replace placeholder URLs with your actual API endpoint \u2014 do not use "example.com" or "YOUR_URL" patterns',
|
|
2462
|
+
28: "Add executable code to the code node \u2014 empty or comment-only code nodes do nothing at runtime",
|
|
2463
|
+
29: "Set the channel parameter for Slack message operations (channelId with __rl object, or channel as string)",
|
|
2464
|
+
30: "Set the to parameter for Gmail send operations with at least one recipient email address",
|
|
2465
|
+
31: "Add at least one condition to the if node \u2014 conditions.conditions array must be non-empty",
|
|
2466
|
+
32: "Add field assignments to the set node \u2014 assignments.assignments array must be non-empty for typeVersion 3.x",
|
|
2467
|
+
33: "Add at least one schedule rule to scheduleTrigger \u2014 rule.interval array must have at least one entry",
|
|
2468
|
+
34: 'Webhook path must be a relative path without spaces, leading slashes, or protocol prefixes (e.g. "my-hook")'
|
|
1954
2469
|
};
|
|
1955
2470
|
|
|
1956
2471
|
// src/telemetry/pattern-analyzer.ts
|
|
@@ -1959,22 +2474,24 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
1959
2474
|
telemetryDir;
|
|
1960
2475
|
outputDir;
|
|
1961
2476
|
_cachedEvents = null;
|
|
2477
|
+
_cachedPreviousPatterns = null;
|
|
1962
2478
|
constructor(telemetryDir) {
|
|
1963
2479
|
const defaultDir = (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos", "telemetry");
|
|
1964
2480
|
this.telemetryDir = telemetryDir ?? defaultDir;
|
|
1965
2481
|
this.outputDir = telemetryDir ? (0, import_node_path5.join)(telemetryDir, "..") : (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos");
|
|
1966
2482
|
}
|
|
1967
2483
|
async loadPreviousPatterns() {
|
|
2484
|
+
if (this._cachedPreviousPatterns !== null) return this._cachedPreviousPatterns;
|
|
1968
2485
|
try {
|
|
1969
2486
|
const raw = await (0, import_promises4.readFile)((0, import_node_path5.join)(this.outputDir, "patterns.json"), "utf-8");
|
|
1970
2487
|
const prev = JSON.parse(raw);
|
|
1971
2488
|
const version = prev.schemaVersion ?? 0;
|
|
1972
2489
|
const patterns = prev.topFailureRules ?? [];
|
|
1973
|
-
|
|
1974
|
-
return this.migratePatterns(patterns, version);
|
|
2490
|
+
this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
|
|
1975
2491
|
} catch {
|
|
1976
|
-
|
|
2492
|
+
this._cachedPreviousPatterns = [];
|
|
1977
2493
|
}
|
|
2494
|
+
return this._cachedPreviousPatterns;
|
|
1978
2495
|
}
|
|
1979
2496
|
migratePatterns(patterns, fromVersion) {
|
|
1980
2497
|
let migrated = patterns;
|
|
@@ -2006,7 +2523,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2006
2523
|
this._cachedEvents = events;
|
|
2007
2524
|
const starts = events.filter((e) => e.eventType === "build_start");
|
|
2008
2525
|
const attempts = events.filter((e) => e.eventType === "generation_attempt");
|
|
2009
|
-
const
|
|
2526
|
+
const _passed = attempts.filter(
|
|
2010
2527
|
(a) => a.data.validationPassed === true
|
|
2011
2528
|
);
|
|
2012
2529
|
const failed = attempts.filter(
|
|
@@ -2274,6 +2791,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2274
2791
|
const tmpPath = `${outputPath}.tmp`;
|
|
2275
2792
|
await (0, import_promises4.writeFile)(tmpPath, JSON.stringify(analysis, null, 2), "utf-8");
|
|
2276
2793
|
await (0, import_promises4.rename)(tmpPath, outputPath);
|
|
2794
|
+
this._cachedPreviousPatterns = null;
|
|
2277
2795
|
const historySummary = {
|
|
2278
2796
|
timestamp: analysis.generatedAt,
|
|
2279
2797
|
totalBuilds: analysis.summary.totalBuilds,
|
|
@@ -2322,7 +2840,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2322
2840
|
})
|
|
2323
2841
|
));
|
|
2324
2842
|
return {
|
|
2325
|
-
sessionId: bc.sessionId,
|
|
2843
|
+
sessionId: bc.runId ?? bc.sessionId,
|
|
2326
2844
|
date: bc.fileDate,
|
|
2327
2845
|
description: data.description ?? "",
|
|
2328
2846
|
workflowType: data.workflowType ?? null,
|
|
@@ -2355,7 +2873,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2355
2873
|
alerts.push({
|
|
2356
2874
|
type: "stale_pattern",
|
|
2357
2875
|
rule: p.rule,
|
|
2358
|
-
message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-
|
|
2876
|
+
message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-34)`
|
|
2359
2877
|
});
|
|
2360
2878
|
}
|
|
2361
2879
|
}
|