n8n-guard 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.
@@ -0,0 +1,58 @@
1
+ import { humanBytes } from "./dbHealth.js";
2
+ /**
3
+ * Turns raw execution counts into "what does deleting old runs actually buy me".
4
+ * Byte figures are estimates: they assume execution rows cost the same on
5
+ * average, which is what the row-count-to-size ratio can support.
6
+ */
7
+ export function analyzeRetention(stats, keepDays = 30, now = Date.now()) {
8
+ const oldest = stats.oldestExecutionAt ? Date.parse(stats.oldestExecutionAt) : NaN;
9
+ const newest = stats.newestExecutionAt ? Date.parse(stats.newestExecutionAt) : NaN;
10
+ const spanDays = Number.isNaN(oldest) || Number.isNaN(newest) ? null : Math.max((newest - oldest) / 86_400_000, 1 / 24);
11
+ const executionsPerDay = spanDays === null ? null : stats.executionCount / spanDays;
12
+ // Only execution-shaped tables shrink when executions are pruned.
13
+ const executionTables = stats.tables.filter((t) => t.table.startsWith("execution_"));
14
+ const executionBytes = executionTables.every((t) => t.bytes === null)
15
+ ? stats.totalBytes || null
16
+ : executionTables.reduce((sum, t) => sum + (t.bytes ?? 0), 0);
17
+ const bytesPerRow = stats.executionCount > 0 && executionBytes ? executionBytes / stats.executionCount : null;
18
+ const buckets = Object.entries(stats.executionsOlderThanDays)
19
+ .map(([days, rows]) => ({
20
+ olderThanDays: Number(days),
21
+ rows,
22
+ share: stats.executionCount > 0 ? rows / stats.executionCount : 0,
23
+ estimatedBytes: bytesPerRow === null ? null : Math.round(rows * bytesPerRow),
24
+ }))
25
+ .sort((a, b) => a.olderThanDays - b.olderThanDays);
26
+ const chosen = buckets.find((b) => b.olderThanDays === keepDays) ?? null;
27
+ const recommendation = chosen
28
+ ? {
29
+ keepDays,
30
+ prunableRows: chosen.rows,
31
+ estimatedBytesFreed: chosen.estimatedBytes,
32
+ estimatedHumanFreed: chosen.estimatedBytes === null ? null : humanBytes(chosen.estimatedBytes),
33
+ }
34
+ : null;
35
+ const findings = [];
36
+ findings.push(`${stats.executionCount} execution records` +
37
+ (spanDays !== null ? ` spanning ${spanDays.toFixed(1)} days (${executionsPerDay.toFixed(1)}/day)` : ""));
38
+ if (recommendation && recommendation.prunableRows > 0) {
39
+ findings.push(`keeping ${keepDays} days would drop ${recommendation.prunableRows} rows` +
40
+ (recommendation.estimatedHumanFreed ? `, roughly ${recommendation.estimatedHumanFreed}` : ""));
41
+ }
42
+ else if (stats.executionCount > 0) {
43
+ findings.push(`nothing older than ${keepDays} days, retention is already tight`);
44
+ }
45
+ return {
46
+ executionCount: stats.executionCount,
47
+ oldestExecutionAt: stats.oldestExecutionAt,
48
+ newestExecutionAt: stats.newestExecutionAt,
49
+ spanDays: spanDays === null ? null : Number(spanDays.toFixed(2)),
50
+ executionsPerDay: executionsPerDay === null ? null : Number(executionsPerDay.toFixed(2)),
51
+ buckets,
52
+ recommendation,
53
+ findings,
54
+ hint: "n8n-guard never deletes. To act on this, set EXECUTIONS_DATA_PRUNE=true and " +
55
+ `EXECUTIONS_DATA_MAX_AGE=${keepDays * 24} (hours) on the n8n instance, then restart it.`,
56
+ };
57
+ }
58
+ //# sourceMappingURL=retention.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention.js","sourceRoot":"","sources":["../../src/checks/retention.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ import type { N8nExecution } from "../n8n/api.js";
2
+ import type { Severity } from "./dbHealth.js";
3
+ export interface StuckExecution {
4
+ id: string;
5
+ workflowId: string | null;
6
+ startedAt: string | null;
7
+ runningForMinutes: number;
8
+ /** How many times the workflow's own median runtime this run has already taken. */
9
+ timesBaseline: number | null;
10
+ severity: Severity;
11
+ reason: string;
12
+ }
13
+ export interface StuckReport {
14
+ thresholdMinutes: number;
15
+ runningCount: number;
16
+ stuck: StuckExecution[];
17
+ /** Median runtime in seconds per workflow, derived from finished executions. */
18
+ baselineSecondsByWorkflow: Record<string, number>;
19
+ overallBaselineSeconds: number | null;
20
+ findings: string[];
21
+ }
22
+ export declare function median(values: number[]): number | null;
23
+ /**
24
+ * Every other n8n MCP server lists executions by status. Listing is not
25
+ * detection: a run stuck in `running` looks exactly like a healthy long job
26
+ * until you compare it against what that workflow normally takes.
27
+ */
28
+ export declare function findStuckExecutions(running: N8nExecution[], finished: N8nExecution[], thresholdMinutes: number, now?: number): StuckReport;
@@ -0,0 +1,95 @@
1
+ function durationSeconds(execution) {
2
+ if (!execution.startedAt || !execution.stoppedAt)
3
+ return null;
4
+ const start = Date.parse(execution.startedAt);
5
+ const stop = Date.parse(execution.stoppedAt);
6
+ if (Number.isNaN(start) || Number.isNaN(stop) || stop < start)
7
+ return null;
8
+ return (stop - start) / 1000;
9
+ }
10
+ export function median(values) {
11
+ if (values.length === 0)
12
+ return null;
13
+ const sorted = [...values].sort((a, b) => a - b);
14
+ const mid = Math.floor(sorted.length / 2);
15
+ return sorted.length % 2 === 0 ? ((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
16
+ }
17
+ /**
18
+ * Every other n8n MCP server lists executions by status. Listing is not
19
+ * detection: a run stuck in `running` looks exactly like a healthy long job
20
+ * until you compare it against what that workflow normally takes.
21
+ */
22
+ export function findStuckExecutions(running, finished, thresholdMinutes, now = Date.now()) {
23
+ const durationsByWorkflow = new Map();
24
+ const allDurations = [];
25
+ for (const execution of finished) {
26
+ const seconds = durationSeconds(execution);
27
+ if (seconds === null)
28
+ continue;
29
+ allDurations.push(seconds);
30
+ const key = String(execution.workflowId ?? "");
31
+ if (!key)
32
+ continue;
33
+ const bucket = durationsByWorkflow.get(key) ?? [];
34
+ bucket.push(seconds);
35
+ durationsByWorkflow.set(key, bucket);
36
+ }
37
+ const baselineSecondsByWorkflow = {};
38
+ for (const [workflowId, values] of durationsByWorkflow) {
39
+ const value = median(values);
40
+ if (value !== null)
41
+ baselineSecondsByWorkflow[workflowId] = value;
42
+ }
43
+ const overallBaselineSeconds = median(allDurations);
44
+ const stuck = [];
45
+ for (const execution of running) {
46
+ if (!execution.startedAt)
47
+ continue;
48
+ const started = Date.parse(execution.startedAt);
49
+ if (Number.isNaN(started))
50
+ continue;
51
+ const runningForMinutes = (now - started) / 60_000;
52
+ if (runningForMinutes < 0)
53
+ continue;
54
+ const workflowId = execution.workflowId ? String(execution.workflowId) : null;
55
+ const baseline = workflowId ? baselineSecondsByWorkflow[workflowId] ?? null : null;
56
+ const effectiveBaseline = baseline ?? overallBaselineSeconds;
57
+ const timesBaseline = effectiveBaseline && effectiveBaseline > 0 ? (runningForMinutes * 60) / effectiveBaseline : null;
58
+ const overThreshold = runningForMinutes >= thresholdMinutes;
59
+ // A run 20x its own median is suspicious even below the absolute threshold,
60
+ // but only once it is past a minute -- sub-second jobs make the ratio noisy.
61
+ const overBaseline = timesBaseline !== null && timesBaseline >= 20 && runningForMinutes >= 1;
62
+ if (!overThreshold && !overBaseline)
63
+ continue;
64
+ const reasons = [];
65
+ if (overThreshold)
66
+ reasons.push(`running ${runningForMinutes.toFixed(1)} min, threshold is ${thresholdMinutes} min`);
67
+ if (overBaseline)
68
+ reasons.push(`${timesBaseline.toFixed(0)}x the ${effectiveBaseline.toFixed(1)}s baseline for this workflow`);
69
+ stuck.push({
70
+ id: String(execution.id),
71
+ workflowId,
72
+ startedAt: execution.startedAt,
73
+ runningForMinutes: Number(runningForMinutes.toFixed(2)),
74
+ timesBaseline: timesBaseline === null ? null : Number(timesBaseline.toFixed(1)),
75
+ severity: overThreshold && runningForMinutes >= thresholdMinutes * 4 ? "critical" : "warning",
76
+ reason: reasons.join("; "),
77
+ });
78
+ }
79
+ stuck.sort((a, b) => b.runningForMinutes - a.runningForMinutes);
80
+ const findings = stuck.length === 0
81
+ ? [`no stuck executions among ${running.length} running`]
82
+ : [
83
+ `${stuck.length} of ${running.length} running executions are stuck`,
84
+ ...stuck.slice(0, 5).map((s) => `execution ${s.id} (workflow ${s.workflowId ?? "unknown"}): ${s.reason}`),
85
+ ];
86
+ return {
87
+ thresholdMinutes,
88
+ runningCount: running.length,
89
+ stuck,
90
+ baselineSecondsByWorkflow,
91
+ overallBaselineSeconds,
92
+ findings,
93
+ };
94
+ }
95
+ //# sourceMappingURL=stuckExecutions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stuckExecutions.js","sourceRoot":"","sources":["../../src/checks/stuckExecutions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every setting comes from the environment. n8n-guard never assumes a host,
3
+ * container name, file path or account: it runs against whatever self-hosted
4
+ * instance the operator points it at.
5
+ */
6
+ export interface GuardConfig {
7
+ /** Base URL of the n8n instance, e.g. http://localhost:5678 */
8
+ n8nUrl: string;
9
+ /** Public API key (Settings -> n8n API). Optional: without it API checks degrade. */
10
+ n8nApiKey: string | undefined;
11
+ /** Absolute path to database.sqlite when n8n runs on SQLite. */
12
+ sqlitePath: string | undefined;
13
+ /** Postgres connection string when n8n runs on Postgres. */
14
+ postgresUrl: string | undefined;
15
+ /** Path to a git checkout holding exported workflow JSON. */
16
+ gitRepoPath: string | undefined;
17
+ /** An execution running longer than this is reported as stuck. */
18
+ stuckThresholdMinutes: number;
19
+ /** Warn when the n8n datastore grows past this size. */
20
+ dbWarnBytes: number;
21
+ /** Treat the datastore as critical past this size. */
22
+ dbCriticalBytes: number;
23
+ /** Per-request timeout for the n8n API, in milliseconds. */
24
+ requestTimeoutMs: number;
25
+ }
26
+ export declare class ConfigError extends Error {
27
+ }
28
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): GuardConfig;
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ export class ConfigError extends Error {
2
+ }
3
+ function num(raw, fallback, name) {
4
+ if (raw === undefined || raw.trim() === "")
5
+ return fallback;
6
+ const parsed = Number(raw);
7
+ if (!Number.isFinite(parsed) || parsed <= 0) {
8
+ throw new ConfigError(`${name} must be a positive number, got ${JSON.stringify(raw)}`);
9
+ }
10
+ return parsed;
11
+ }
12
+ function trimmed(raw) {
13
+ const value = raw?.trim();
14
+ return value ? value : undefined;
15
+ }
16
+ export function loadConfig(env = process.env) {
17
+ const n8nUrl = trimmed(env.N8N_URL) ?? "http://localhost:5678";
18
+ try {
19
+ new URL(n8nUrl);
20
+ }
21
+ catch {
22
+ throw new ConfigError(`N8N_URL is not a valid URL: ${JSON.stringify(n8nUrl)}`);
23
+ }
24
+ return {
25
+ n8nUrl: n8nUrl.replace(/\/+$/, ""),
26
+ n8nApiKey: trimmed(env.N8N_API_KEY),
27
+ sqlitePath: trimmed(env.N8N_SQLITE_PATH),
28
+ postgresUrl: trimmed(env.N8N_POSTGRES_URL),
29
+ gitRepoPath: trimmed(env.N8N_GIT_REPO_PATH),
30
+ stuckThresholdMinutes: num(env.N8N_GUARD_STUCK_MINUTES, 15, "N8N_GUARD_STUCK_MINUTES"),
31
+ dbWarnBytes: num(env.N8N_GUARD_DB_WARN_BYTES, 1024 ** 3, "N8N_GUARD_DB_WARN_BYTES"),
32
+ dbCriticalBytes: num(env.N8N_GUARD_DB_CRITICAL_BYTES, 4 * 1024 ** 3, "N8N_GUARD_DB_CRITICAL_BYTES"),
33
+ requestTimeoutMs: num(env.N8N_GUARD_TIMEOUT_MS, 15_000, "N8N_GUARD_TIMEOUT_MS"),
34
+ };
35
+ }
36
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AA0BA,MAAM,OAAO,WAAY,SAAQ,KAAK;CAAG;AAEzC,SAAS,GAAG,CAAC,GAAuB,EAAE,QAAgB,EAAE,IAAY;IAClE,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IAC5D,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,mCAAmC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IACzF,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,OAAO,CAAC,GAAuB;IACtC,MAAM,KAAK,GAAG,GAAG,EAAE,IAAI,EAAE,CAAC;IAC1B,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,MAAyB,OAAO,CAAC,GAAG;IAC7D,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,uBAAuB,CAAC;IAC/D,IAAI,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,WAAW,CAAC,+BAA+B,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACjF,CAAC;IAED,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAClC,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC;QACnC,UAAU,EAAE,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;QACxC,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;QAC1C,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;QAC3C,qBAAqB,EAAE,GAAG,CAAC,GAAG,CAAC,uBAAuB,EAAE,EAAE,EAAE,yBAAyB,CAAC;QACtF,WAAW,EAAE,GAAG,CAAC,GAAG,CAAC,uBAAuB,EAAE,IAAI,IAAI,CAAC,EAAE,yBAAyB,CAAC;QACnF,eAAe,EAAE,GAAG,CAAC,GAAG,CAAC,2BAA2B,EAAE,CAAC,GAAG,IAAI,IAAI,CAAC,EAAE,6BAA6B,CAAC;QACnG,gBAAgB,EAAE,GAAG,CAAC,GAAG,CAAC,oBAAoB,EAAE,MAAM,EAAE,sBAAsB,CAAC;KAChF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,49 @@
1
+ export type StepStatus = "ok" | "failed" | "skipped";
2
+ export interface Step<T = unknown> {
3
+ name: string;
4
+ /** Human-readable purpose, surfaced in the report so a partial run still explains itself. */
5
+ description: string;
6
+ run: () => Promise<T>;
7
+ /** Extra attempts after the first one. Transient failures are the norm here. */
8
+ retries?: number;
9
+ /** Names of steps whose results this one needs. A failed dependency skips it. */
10
+ dependsOn?: string[];
11
+ }
12
+ export interface StepResult {
13
+ name: string;
14
+ description: string;
15
+ status: StepStatus;
16
+ attempts: number;
17
+ durationMs: number;
18
+ result?: unknown;
19
+ error?: string;
20
+ /** Set when the step was skipped: which dependency was not available. */
21
+ skippedBecause?: string;
22
+ }
23
+ export interface GuardRunReport {
24
+ startedAt: string;
25
+ finishedAt: string;
26
+ durationMs: number;
27
+ /** true when at least one step failed but the run still produced results. */
28
+ degraded: boolean;
29
+ summary: {
30
+ ok: number;
31
+ failed: number;
32
+ skipped: number;
33
+ };
34
+ steps: StepResult[];
35
+ findings: string[];
36
+ }
37
+ export interface RunOptions {
38
+ /** Delay between retries; injectable so tests do not sleep. */
39
+ backoff?: (attempt: number) => Promise<void>;
40
+ now?: () => number;
41
+ }
42
+ /**
43
+ * The recovery core. A monitoring run that aborts on the first unreachable
44
+ * dependency is worthless precisely when it matters: the API being down is the
45
+ * incident, not a reason to stop looking at the disk. Every step is isolated,
46
+ * retried, and its failure is reported as a finding rather than thrown.
47
+ */
48
+ export declare function runSteps(steps: Step[], options?: RunOptions): Promise<GuardRunReport>;
49
+ export declare function errorMessage(error: unknown): string;
@@ -0,0 +1,95 @@
1
+ const defaultBackoff = (attempt) => new Promise((resolve) => setTimeout(resolve, Math.min(250 * 2 ** (attempt - 1), 4000)));
2
+ /**
3
+ * The recovery core. A monitoring run that aborts on the first unreachable
4
+ * dependency is worthless precisely when it matters: the API being down is the
5
+ * incident, not a reason to stop looking at the disk. Every step is isolated,
6
+ * retried, and its failure is reported as a finding rather than thrown.
7
+ */
8
+ export async function runSteps(steps, options = {}) {
9
+ const backoff = options.backoff ?? defaultBackoff;
10
+ const now = options.now ?? Date.now;
11
+ const startedAtMs = now();
12
+ const results = [];
13
+ const byName = new Map();
14
+ for (const step of steps) {
15
+ const failedDependency = (step.dependsOn ?? []).find((dep) => byName.get(dep)?.status !== "ok");
16
+ if (failedDependency) {
17
+ const record = {
18
+ name: step.name,
19
+ description: step.description,
20
+ status: "skipped",
21
+ attempts: 0,
22
+ durationMs: 0,
23
+ skippedBecause: byName.has(failedDependency)
24
+ ? `depends on ${failedDependency}, which ${byName.get(failedDependency).status}`
25
+ : `depends on ${failedDependency}, which did not run`,
26
+ };
27
+ results.push(record);
28
+ byName.set(step.name, record);
29
+ continue;
30
+ }
31
+ const stepStart = now();
32
+ const maxAttempts = 1 + Math.max(0, step.retries ?? 0);
33
+ let attempts = 0;
34
+ let lastError;
35
+ let value;
36
+ let ok = false;
37
+ while (attempts < maxAttempts) {
38
+ attempts += 1;
39
+ try {
40
+ value = await step.run();
41
+ ok = true;
42
+ break;
43
+ }
44
+ catch (error) {
45
+ lastError = error;
46
+ if (attempts < maxAttempts)
47
+ await backoff(attempts);
48
+ }
49
+ }
50
+ const record = ok
51
+ ? { name: step.name, description: step.description, status: "ok", attempts, durationMs: now() - stepStart, result: value }
52
+ : {
53
+ name: step.name,
54
+ description: step.description,
55
+ status: "failed",
56
+ attempts,
57
+ durationMs: now() - stepStart,
58
+ error: errorMessage(lastError),
59
+ };
60
+ results.push(record);
61
+ byName.set(step.name, record);
62
+ }
63
+ const summary = {
64
+ ok: results.filter((r) => r.status === "ok").length,
65
+ failed: results.filter((r) => r.status === "failed").length,
66
+ skipped: results.filter((r) => r.status === "skipped").length,
67
+ };
68
+ const findings = [];
69
+ for (const record of results) {
70
+ if (record.status === "failed")
71
+ findings.push(`${record.name} failed after ${record.attempts} attempt(s): ${record.error}`);
72
+ if (record.status === "skipped")
73
+ findings.push(`${record.name} skipped: ${record.skippedBecause}`);
74
+ }
75
+ if (summary.failed === 0 && summary.skipped === 0)
76
+ findings.push(`all ${summary.ok} checks completed`);
77
+ else
78
+ findings.push(`${summary.ok} of ${results.length} checks completed despite the failures above`);
79
+ const finishedAtMs = now();
80
+ return {
81
+ startedAt: new Date(startedAtMs).toISOString(),
82
+ finishedAt: new Date(finishedAtMs).toISOString(),
83
+ durationMs: finishedAtMs - startedAtMs,
84
+ degraded: summary.failed > 0 || summary.skipped > 0,
85
+ summary,
86
+ steps: results,
87
+ findings,
88
+ };
89
+ }
90
+ export function errorMessage(error) {
91
+ if (error instanceof Error)
92
+ return error.message;
93
+ return String(error);
94
+ }
95
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../src/guard/runner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ import type { GuardConfig } from "../config.js";
2
+ import { N8nApi } from "../n8n/api.js";
3
+ import { type DatastoreStats } from "../n8n/datastore.js";
4
+ import { type DbHealthReport } from "../checks/dbHealth.js";
5
+ import { type StuckReport } from "../checks/stuckExecutions.js";
6
+ import { type RetentionReport } from "../checks/retention.js";
7
+ import { type GitDriftReport } from "../checks/gitDrift.js";
8
+ import { type GuardRunReport, type RunOptions } from "./runner.js";
9
+ export interface InstanceInfo {
10
+ url: string;
11
+ reachable: boolean;
12
+ detail: string;
13
+ apiKeyConfigured: boolean;
14
+ datastore: string;
15
+ gitRepoConfigured: boolean;
16
+ workflowCount: number | null;
17
+ activeWorkflowCount: number | null;
18
+ }
19
+ /**
20
+ * One place that knows how to read the instance. The MCP tool layer above it
21
+ * only formats; the check modules below it stay pure and testable.
22
+ */
23
+ export declare class GuardService {
24
+ readonly config: GuardConfig;
25
+ readonly api: N8nApi;
26
+ constructor(config: GuardConfig);
27
+ instanceInfo(): Promise<InstanceInfo>;
28
+ datastoreStats(): Promise<DatastoreStats>;
29
+ dbHealth(stats?: DatastoreStats): Promise<DbHealthReport>;
30
+ stuckExecutions(thresholdMinutes?: number): Promise<StuckReport>;
31
+ retention(keepDays?: number, stats?: DatastoreStats): Promise<RetentionReport>;
32
+ gitDrift(): Promise<GitDriftReport>;
33
+ /**
34
+ * The background sweep for the `render` track: it survives a partial outage.
35
+ * A dead API still leaves disk and retention answerable; a missing git repo
36
+ * still leaves the API answerable. Failures become findings, not an abort.
37
+ */
38
+ fullRun(options?: {
39
+ keepDays?: number;
40
+ runOptions?: RunOptions;
41
+ }): Promise<GuardRunReport>;
42
+ }
@@ -0,0 +1,128 @@
1
+ import { N8nApi } from "../n8n/api.js";
2
+ import { readDatastoreStats, resolveDatastoreKind } from "../n8n/datastore.js";
3
+ import { assessDatabaseHealth, humanBytes } from "../checks/dbHealth.js";
4
+ import { findStuckExecutions } from "../checks/stuckExecutions.js";
5
+ import { analyzeRetention } from "../checks/retention.js";
6
+ import { diffWorkflows, loadExportedWorkflows } from "../checks/gitDrift.js";
7
+ import { runSteps } from "./runner.js";
8
+ /**
9
+ * One place that knows how to read the instance. The MCP tool layer above it
10
+ * only formats; the check modules below it stay pure and testable.
11
+ */
12
+ export class GuardService {
13
+ config;
14
+ api;
15
+ constructor(config) {
16
+ this.config = config;
17
+ this.api = new N8nApi(config);
18
+ }
19
+ async instanceInfo() {
20
+ const health = await this.api.health();
21
+ let workflows = null;
22
+ if (this.config.n8nApiKey) {
23
+ workflows = await this.api.listWorkflows().catch(() => null);
24
+ }
25
+ let datastore;
26
+ try {
27
+ datastore = resolveDatastoreKind(this.config);
28
+ }
29
+ catch {
30
+ datastore = "not configured";
31
+ }
32
+ return {
33
+ url: this.config.n8nUrl,
34
+ reachable: health.reachable,
35
+ detail: health.detail,
36
+ apiKeyConfigured: Boolean(this.config.n8nApiKey),
37
+ datastore,
38
+ gitRepoConfigured: Boolean(this.config.gitRepoPath),
39
+ workflowCount: workflows?.length ?? null,
40
+ activeWorkflowCount: workflows?.filter((w) => w.active).length ?? null,
41
+ };
42
+ }
43
+ datastoreStats() {
44
+ return readDatastoreStats(this.config);
45
+ }
46
+ async dbHealth(stats) {
47
+ const resolved = stats ?? (await this.datastoreStats());
48
+ return assessDatabaseHealth(resolved, {
49
+ warnBytes: this.config.dbWarnBytes,
50
+ criticalBytes: this.config.dbCriticalBytes,
51
+ });
52
+ }
53
+ async stuckExecutions(thresholdMinutes = this.config.stuckThresholdMinutes) {
54
+ const [running, success, failed] = await Promise.all([
55
+ this.api.listExecutions("running", 500),
56
+ this.api.listExecutions("success", 500),
57
+ this.api.listExecutions("error", 200),
58
+ ]);
59
+ const finished = [...success, ...failed];
60
+ return findStuckExecutions(running, finished, thresholdMinutes);
61
+ }
62
+ async retention(keepDays = 30, stats) {
63
+ const resolved = stats ?? (await this.datastoreStats());
64
+ return analyzeRetention(resolved, keepDays);
65
+ }
66
+ async gitDrift() {
67
+ if (!this.config.gitRepoPath) {
68
+ throw new Error("N8N_GIT_REPO_PATH is not set, so there is nothing to diff the instance against");
69
+ }
70
+ const [live, exported] = await Promise.all([
71
+ this.api.listWorkflows().then((list) => Promise.all(list.map((w) => this.api.getWorkflow(String(w.id))))),
72
+ loadExportedWorkflows(this.config.gitRepoPath),
73
+ ]);
74
+ return diffWorkflows(live, exported);
75
+ }
76
+ /**
77
+ * The background sweep for the `render` track: it survives a partial outage.
78
+ * A dead API still leaves disk and retention answerable; a missing git repo
79
+ * still leaves the API answerable. Failures become findings, not an abort.
80
+ */
81
+ async fullRun(options = {}) {
82
+ const keepDays = options.keepDays ?? 30;
83
+ let stats;
84
+ const steps = [
85
+ {
86
+ name: "instance",
87
+ description: "reach the n8n instance and read its workflow inventory",
88
+ retries: 2,
89
+ run: () => this.instanceInfo(),
90
+ },
91
+ {
92
+ name: "datastore",
93
+ description: "read size and row counts from the n8n datastore",
94
+ retries: 1,
95
+ run: async () => {
96
+ stats = await this.datastoreStats();
97
+ return { kind: stats.kind, totalBytes: stats.totalBytes, totalHuman: humanBytes(stats.totalBytes) };
98
+ },
99
+ },
100
+ {
101
+ name: "db_health",
102
+ description: "judge datastore size and growth against the configured thresholds",
103
+ dependsOn: ["datastore"],
104
+ run: () => this.dbHealth(stats),
105
+ },
106
+ {
107
+ name: "retention",
108
+ description: "estimate what pruning old executions would free",
109
+ dependsOn: ["datastore"],
110
+ run: () => this.retention(keepDays, stats),
111
+ },
112
+ {
113
+ name: "stuck_executions",
114
+ description: "detect executions stuck in running against their own baseline",
115
+ retries: 2,
116
+ run: () => this.stuckExecutions(),
117
+ },
118
+ {
119
+ name: "git_drift",
120
+ description: "compare live workflows against the exports in the git repository",
121
+ retries: 1,
122
+ run: () => this.gitDrift(),
123
+ },
124
+ ];
125
+ return runSteps(steps, options.runOptions);
126
+ }
127
+ }
128
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../../src/guard/service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "./config.js";
4
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
5
+ async function main() {
6
+ const config = loadConfig();
7
+ const server = createServer(config);
8
+ // stdout carries the protocol, so every human-facing line goes to stderr.
9
+ console.error(`${SERVER_NAME} ${SERVER_VERSION} watching ${config.n8nUrl}`);
10
+ await server.connect(new StdioServerTransport());
11
+ }
12
+ main().catch((error) => {
13
+ console.error(`${SERVER_NAME} failed to start: ${error instanceof Error ? error.message : String(error)}`);
14
+ process.exit(1);
15
+ });
16
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAExE,KAAK,UAAU,IAAI;IACjB,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC;IAC5B,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACpC,0EAA0E;IAC1E,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,IAAI,cAAc,aAAa,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5E,MAAM,MAAM,CAAC,OAAO,CAAC,IAAI,oBAAoB,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;IAC9B,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,qBAAqB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAC3G,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}