sparda-mcp 0.3.0 → 0.5.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/README.md +6 -0
- package/package.json +1 -1
- package/src/commands/hook.js +22 -0
- package/src/commands/init.js +12 -11
- package/src/commands/remove.js +2 -0
- package/src/generator/express.js +26 -1
- package/src/generator/fastapi.js +26 -1
- package/src/generator/manifest.js +20 -4
- package/src/parser/express.js +43 -1
- package/src/parser/fastapi_extract.py +132 -32
- package/src/server/condenser.js +145 -0
- package/src/server/crystallize.js +89 -0
- package/src/server/idle.js +42 -0
- package/src/server/stdio.js +248 -8
- package/src/ui/style.js +66 -0
- package/templates/express-router.txt +215 -20
- package/templates/fastapi-router.txt +208 -35
package/src/ui/style.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ui/style.js — zero-dep ANSI styling for the HUMAN commands (init, remove,
|
|
2
|
+
// doctor). Never import this from the bridge path: there, stdout is the MCP
|
|
3
|
+
// protocol (hard rule #2) and stderr must stay grep-able plain text.
|
|
4
|
+
|
|
5
|
+
// SPARDA identity: violet → cyan, same stops as the brand gradient
|
|
6
|
+
const VIOLET = [192, 132, 252]; // #c084fc
|
|
7
|
+
const CYAN = [103, 232, 249]; // #67e8f9
|
|
8
|
+
const RESET = '\x1b[0m';
|
|
9
|
+
|
|
10
|
+
// evaluated per call so tests (and runtime env changes) see the truth;
|
|
11
|
+
// honors https://no-color.org and the FORCE_COLOR convention
|
|
12
|
+
function enabled() {
|
|
13
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
|
|
14
|
+
if (process.env.NO_COLOR !== undefined) return false;
|
|
15
|
+
return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function truecolor() {
|
|
19
|
+
return /truecolor|24bit/i.test(process.env.COLORTERM ?? '') ||
|
|
20
|
+
['iTerm.app', 'vscode', 'WezTerm', 'ghostty'].includes(process.env.TERM_PROGRAM ?? '') ||
|
|
21
|
+
Boolean(process.env.WT_SESSION);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sgr = (open) => (s) => (enabled() ? `\x1b[${open}m${s}${RESET}` : String(s));
|
|
25
|
+
|
|
26
|
+
export const c = {
|
|
27
|
+
dim: sgr('2'),
|
|
28
|
+
bold: sgr('1'),
|
|
29
|
+
green: sgr('32'),
|
|
30
|
+
yellow: sgr('33'),
|
|
31
|
+
red: sgr('31'),
|
|
32
|
+
cyan: sgr('38;5;117'),
|
|
33
|
+
violet: sgr('38;5;177'),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// the brand banner: per-character violet→cyan interpolation in truecolor,
|
|
37
|
+
// a stepped 256-color ramp elsewhere, plain text when colors are off
|
|
38
|
+
export function gradient(text) {
|
|
39
|
+
if (!enabled()) return text;
|
|
40
|
+
const chars = [...text];
|
|
41
|
+
if (truecolor()) {
|
|
42
|
+
const body = chars.map((ch, i) => {
|
|
43
|
+
const t = chars.length === 1 ? 0 : i / (chars.length - 1);
|
|
44
|
+
const [r, g, b] = VIOLET.map((v, k) => Math.round(v + (CYAN[k] - v) * t));
|
|
45
|
+
return `\x1b[1m\x1b[38;2;${r};${g};${b}m${ch}`;
|
|
46
|
+
}).join('');
|
|
47
|
+
return body + RESET;
|
|
48
|
+
}
|
|
49
|
+
const ramp = [177, 141, 147, 153, 117];
|
|
50
|
+
const body = chars.map((ch, i) => {
|
|
51
|
+
const step = ramp[Math.min(ramp.length - 1, Math.floor((i / chars.length) * ramp.length))];
|
|
52
|
+
return `\x1b[1m\x1b[38;5;${step}m${ch}`;
|
|
53
|
+
}).join('');
|
|
54
|
+
return body + RESET;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// single-pass JSON highlighter (keys violet, strings cyan, punctuation dim) —
|
|
58
|
+
// one pass on purpose: a second regex pass would chew the ANSI escapes of the first
|
|
59
|
+
export function colorizeJson(json) {
|
|
60
|
+
if (!enabled()) return json;
|
|
61
|
+
return json.replace(/("(?:[^"\\]|\\.)*")(\s*:)?|([{}[\],])/g, (m, str, colon, punct) => {
|
|
62
|
+
if (punct) return c.dim(punct);
|
|
63
|
+
if (colon !== undefined) return c.violet(str) + c.dim(colon);
|
|
64
|
+
return c.cyan(str);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
__IMPORT_LINE__
|
|
6
6
|
|
|
7
7
|
const SPARDA_TOOLS = __TOOLS_JSON__;
|
|
8
|
+
const SPARDA_POLICIES = __SPARDING_POLICIES__;
|
|
8
9
|
|
|
9
10
|
const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
|
|
10
11
|
const SPARDA_PORT = __PORT__;
|
|
@@ -15,6 +16,45 @@ let SPARDA_SEQ = 0;
|
|
|
15
16
|
// immune system: tools that keep failing are quarantined so the AI can't hammer a broken route
|
|
16
17
|
const SPARDA_QUARANTINE__STATS_TYPE__ = {};
|
|
17
18
|
const SPARDA_QUARANTINE_MS = Number(process.env.SPARDA_QUARANTINE_MS ?? 60000);
|
|
19
|
+
// recycling gauge: calls answered from SPARDA's own knowledge vs calls that paid the host route.
|
|
20
|
+
// Day 1 it reads 0% — the circle fills with usage (a measure, never a promise).
|
|
21
|
+
const SPARDA_RECYCLE__STATS_TYPE__ = { servedByCircle: 0, paidFull: 0 };
|
|
22
|
+
function spardaRecycleRate() {
|
|
23
|
+
const total = SPARDA_RECYCLE.servedByCircle + SPARDA_RECYCLE.paidFull;
|
|
24
|
+
return total ? Math.round((SPARDA_RECYCLE.servedByCircle * 100) / total) : 0;
|
|
25
|
+
}
|
|
26
|
+
// thermodynamic route classification: a GET whose repeated identical args keep
|
|
27
|
+
// returning identical bodies is observed-pure (its result pre-exists — recyclable);
|
|
28
|
+
// writes erase by definition. Observation only, never a guess.
|
|
29
|
+
const SPARDA_PURITY__STATS_TYPE__ = {};
|
|
30
|
+
function spardaHash(s__ANY_TYPE__) {
|
|
31
|
+
let h = 0x811c9dc5; // FNV-1a, capped: a fingerprint, not a checksum of megabytes
|
|
32
|
+
for (let i = 0; i < s.length && i < 65536; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
33
|
+
return h;
|
|
34
|
+
}
|
|
35
|
+
function spardaObservePurity(tool__ANY_TYPE__, argsig__ANY_TYPE__, body__ANY_TYPE__) {
|
|
36
|
+
const pu = SPARDA_PURITY[tool] ?? (SPARDA_PURITY[tool] = { sigs: {}, repeats: 0, mismatches: 0 });
|
|
37
|
+
const h = spardaHash(body);
|
|
38
|
+
const known = pu.sigs[argsig];
|
|
39
|
+
if (known === undefined) {
|
|
40
|
+
if (Object.keys(pu.sigs).length >= 20) return; // bounded: enough sigs to judge
|
|
41
|
+
pu.sigs[argsig] = h;
|
|
42
|
+
} else if (known === h) pu.repeats += 1;
|
|
43
|
+
else { pu.mismatches += 1; pu.sigs[argsig] = h; } // the latest real answer is the truth
|
|
44
|
+
}
|
|
45
|
+
function spardaPuritySnapshot() {
|
|
46
|
+
const out__STATS_TYPE__ = {};
|
|
47
|
+
for (const [name, spec] of Object.entries(SPARDA_TOOLS)) {
|
|
48
|
+
if (spec.method !== 'GET') { out[name] = { class: 'erasing', repeats: 0, mismatches: 0 }; continue; }
|
|
49
|
+
const pu = SPARDA_PURITY[name];
|
|
50
|
+
out[name] = {
|
|
51
|
+
class: !pu ? 'unknown' : pu.mismatches > 0 ? 'volatile' : pu.repeats >= 3 ? 'pure' : 'unknown',
|
|
52
|
+
repeats: pu ? pu.repeats : 0,
|
|
53
|
+
mismatches: pu ? pu.mismatches : 0,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
18
58
|
function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, message__ANY_TYPE__) {
|
|
19
59
|
SPARDA_EVENTS.push({ seq: ++SPARDA_SEQ, ts: new Date().toISOString(), source, tool, status, message: String(message ?? '').slice(0, 500) });
|
|
20
60
|
if (SPARDA_EVENTS.length > 100) SPARDA_EVENTS.shift();
|
|
@@ -22,6 +62,134 @@ function spardaEvent(source__ANY_TYPE__, tool__ANY_TYPE__, status__ANY_TYPE__, m
|
|
|
22
62
|
// observe-only: uncaughtExceptionMonitor never changes the host app's crash behavior
|
|
23
63
|
process.on('uncaughtExceptionMonitor', (err__ANY_TYPE__) => spardaEvent('process', null, null, err && err.message ? err.message : err));
|
|
24
64
|
|
|
65
|
+
function spardaProof(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE__) {
|
|
66
|
+
const checks = {
|
|
67
|
+
knownTool: spec !== undefined,
|
|
68
|
+
enabled: spec ? spec.enabled : false,
|
|
69
|
+
loopSafe: spec ? !spec.path.startsWith('/mcp') : false,
|
|
70
|
+
methodClass: spec ? (spec.method === 'GET' ? 'read' : 'write') : 'read',
|
|
71
|
+
pathParamsPresent: true,
|
|
72
|
+
hasBodyForWrite: true,
|
|
73
|
+
reversibleHint: false,
|
|
74
|
+
quarantineSafe: true,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const reasons = [];
|
|
78
|
+
|
|
79
|
+
if (!checks.knownTool) {
|
|
80
|
+
reasons.push('unknown tool');
|
|
81
|
+
return {
|
|
82
|
+
version: 'sparding-proof/v0.1',
|
|
83
|
+
risk: 'blocked',
|
|
84
|
+
decision: 'block',
|
|
85
|
+
reasons,
|
|
86
|
+
checks,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!checks.enabled) {
|
|
91
|
+
reasons.push('tool disabled (write-safety)');
|
|
92
|
+
}
|
|
93
|
+
if (!checks.loopSafe) {
|
|
94
|
+
reasons.push('self-referential tool blocked (loop protection)');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const quarantined = SPARDA_QUARANTINE[tool];
|
|
98
|
+
if (quarantined) {
|
|
99
|
+
if (Date.now() < quarantined.until) {
|
|
100
|
+
checks.quarantineSafe = false;
|
|
101
|
+
reasons.push(`tool quarantined (immune system): ${quarantined.reason}`);
|
|
102
|
+
} else {
|
|
103
|
+
delete SPARDA_QUARANTINE[tool];
|
|
104
|
+
if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const name of spec.pathParams ?? []) {
|
|
109
|
+
if (args[name] === undefined) {
|
|
110
|
+
checks.pathParamsPresent = false;
|
|
111
|
+
reasons.push(`missing path param: ${name}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (checks.methodClass === 'write') {
|
|
116
|
+
if (args.body === undefined) {
|
|
117
|
+
checks.hasBodyForWrite = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (spec.method === 'GET') {
|
|
122
|
+
checks.reversibleHint = true;
|
|
123
|
+
} else {
|
|
124
|
+
checks.reversibleHint = Object.values(SPARDA_TOOLS).some((t__ANY_TYPE__) => t.method === 'GET' && t.path === spec.path);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let risk = 'low';
|
|
128
|
+
let decision = 'allow';
|
|
129
|
+
|
|
130
|
+
if (!checks.enabled || !checks.loopSafe || !checks.quarantineSafe || !checks.pathParamsPresent) {
|
|
131
|
+
decision = 'block';
|
|
132
|
+
risk = 'blocked';
|
|
133
|
+
return {
|
|
134
|
+
version: 'sparding-proof/v0.1',
|
|
135
|
+
risk,
|
|
136
|
+
decision,
|
|
137
|
+
reasons,
|
|
138
|
+
checks,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const policies = SPARDA_POLICIES ?? {};
|
|
143
|
+
if (checks.methodClass === 'read') {
|
|
144
|
+
const readPolicy = policies.reads ?? 'allow';
|
|
145
|
+
if (readPolicy === 'block') {
|
|
146
|
+
decision = 'block';
|
|
147
|
+
risk = 'blocked';
|
|
148
|
+
reasons.push('read policy blocks execution');
|
|
149
|
+
} else if (readPolicy === 'require_human') {
|
|
150
|
+
decision = 'require_human';
|
|
151
|
+
risk = 'medium';
|
|
152
|
+
reasons.push('read policy requires human confirmation');
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const isDelete = spec.method === 'DELETE';
|
|
156
|
+
const deletePolicy = policies.deletes ?? 'block';
|
|
157
|
+
const writePolicy = policies.writes ?? 'require_human';
|
|
158
|
+
|
|
159
|
+
if (isDelete && deletePolicy === 'block') {
|
|
160
|
+
decision = 'block';
|
|
161
|
+
risk = 'blocked';
|
|
162
|
+
reasons.push('delete policy blocks execution');
|
|
163
|
+
} else if (isDelete && deletePolicy === 'require_human') {
|
|
164
|
+
decision = 'require_human';
|
|
165
|
+
risk = 'high';
|
|
166
|
+
reasons.push('delete operation requires human confirmation');
|
|
167
|
+
} else if (isDelete && deletePolicy === 'allow') {
|
|
168
|
+
decision = 'allow';
|
|
169
|
+
risk = 'low';
|
|
170
|
+
} else if (writePolicy === 'block') {
|
|
171
|
+
decision = 'block';
|
|
172
|
+
risk = 'blocked';
|
|
173
|
+
reasons.push('write policy blocks execution');
|
|
174
|
+
} else if (writePolicy === 'require_human') {
|
|
175
|
+
decision = 'require_human';
|
|
176
|
+
risk = 'medium';
|
|
177
|
+
reasons.push('write operation requires human confirmation');
|
|
178
|
+
} else {
|
|
179
|
+
decision = 'allow';
|
|
180
|
+
risk = 'low';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
version: 'sparding-proof/v0.1',
|
|
186
|
+
risk,
|
|
187
|
+
decision,
|
|
188
|
+
reasons,
|
|
189
|
+
checks,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
25
193
|
__ROUTER_DECL__
|
|
26
194
|
|
|
27
195
|
spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
|
|
@@ -33,7 +201,7 @@ spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
|
|
|
33
201
|
|
|
34
202
|
spardaRouter.get('/tools', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json(SPARDA_TOOLS));
|
|
35
203
|
|
|
36
|
-
spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE }));
|
|
204
|
+
spardaRouter.get('/stats', (_req__REQ_TYPE__, res__RES_TYPE__) => res.json({ uptimeSec: Math.round(process.uptime()), stats: SPARDA_STATS, quarantine: SPARDA_QUARANTINE, recycle: { servedByCircle: SPARDA_RECYCLE.servedByCircle, paidFull: SPARDA_RECYCLE.paidFull, ratePct: spardaRecycleRate() }, purity: spardaPuritySnapshot() }));
|
|
37
205
|
|
|
38
206
|
spardaRouter.get('/events', (req__REQ_TYPE__, res__RES_TYPE__) => {
|
|
39
207
|
const since = Number(req.query.since ?? 0) || 0;
|
|
@@ -44,26 +212,46 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
|
|
|
44
212
|
const { tool, args = {} } = req.body ?? {};
|
|
45
213
|
const t0 = Date.now();
|
|
46
214
|
const spec = SPARDA_TOOLS[tool];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
215
|
+
|
|
216
|
+
const proof = spardaProof(tool, spec, args);
|
|
217
|
+
|
|
218
|
+
if (proof.decision === 'block') {
|
|
219
|
+
let status = 400;
|
|
220
|
+
let error = 'bad request';
|
|
221
|
+
if (!proof.checks.knownTool) {
|
|
222
|
+
status = 404;
|
|
223
|
+
error = `unknown tool: ${tool}`;
|
|
224
|
+
} else if (!proof.checks.enabled) {
|
|
225
|
+
status = 403;
|
|
226
|
+
error = `tool disabled (write-safety): ${tool}`;
|
|
227
|
+
return res.status(status).json({ error, hint: 'Enable it in sparda.json, then re-run: npx sparda-mcp init', spardingProof: proof });
|
|
228
|
+
} else if (!proof.checks.loopSafe) {
|
|
229
|
+
status = 400;
|
|
230
|
+
error = 'self-referential tool blocked (loop protection)';
|
|
231
|
+
} else if (!proof.checks.quarantineSafe) {
|
|
232
|
+
status = 503;
|
|
233
|
+
const quarantined = SPARDA_QUARANTINE[tool];
|
|
234
|
+
error = `tool quarantined (immune system): ${tool}`;
|
|
235
|
+
SPARDA_RECYCLE.servedByCircle += 1;
|
|
236
|
+
return res.status(status).json({
|
|
237
|
+
error,
|
|
238
|
+
reason: quarantined ? quarantined.reason : '',
|
|
239
|
+
retryInMs: quarantined ? Math.max(0, quarantined.until - Date.now()) : 0,
|
|
240
|
+
spardingProof: proof
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
status = 400;
|
|
244
|
+
error = proof.reasons[0] || 'blocked';
|
|
245
|
+
if (proof.reasons.some((r__ANY_TYPE__) => r.includes('policy blocks execution'))) {
|
|
246
|
+
status = 403;
|
|
247
|
+
}
|
|
58
248
|
}
|
|
59
|
-
|
|
60
|
-
delete SPARDA_QUARANTINE[tool];
|
|
61
|
-
if (SPARDA_STATS[tool]) SPARDA_STATS[tool].consecutive5xx = 2;
|
|
249
|
+
return res.status(status).json({ error, spardingProof: proof });
|
|
62
250
|
}
|
|
251
|
+
|
|
63
252
|
try {
|
|
64
253
|
let url = spec.path.replace(/:(\w+)/g, (_, name) => {
|
|
65
254
|
const v = args[name];
|
|
66
|
-
if (v === undefined) throw Object.assign(new Error(`missing path param: ${name}`), { status: 400 });
|
|
67
255
|
return encodeURIComponent(String(v));
|
|
68
256
|
});
|
|
69
257
|
const query = [];
|
|
@@ -78,28 +266,35 @@ spardaRouter.post('/invoke', __JSON_MW__, async (req__REQ_TYPE__, res__RES_TYPE_
|
|
|
78
266
|
init.headers['content-type'] = 'application/json';
|
|
79
267
|
init.body = JSON.stringify(args.body);
|
|
80
268
|
}
|
|
269
|
+
SPARDA_RECYCLE.paidFull += 1; // the host route is about to be exercised — full price
|
|
81
270
|
const upstream = await fetch(`http://127.0.0.1:${SPARDA_PORT}${url}`, { ...init, signal: AbortSignal.timeout(30_000) });
|
|
82
271
|
const text = await upstream.text();
|
|
83
272
|
let data; try { data = JSON.parse(text); } catch { data = text; }
|
|
84
273
|
spardaRecord(tool, upstream.status, Date.now() - t0);
|
|
274
|
+
if (spec.method === 'GET' && upstream.status === 200) {
|
|
275
|
+
// canonical argsig: sorted entries, so the AI's argument order never splits a signature
|
|
276
|
+
spardaObservePurity(tool, JSON.stringify(Object.entries(args).filter((e__ANY_TYPE__) => e[1] !== undefined).sort()), text);
|
|
277
|
+
}
|
|
85
278
|
if (upstream.status >= 500) spardaEvent('invoke', tool, upstream.status, text.slice(0, 200));
|
|
86
|
-
return res.status(200).json({ upstreamStatus: upstream.status, data });
|
|
279
|
+
return res.status(200).json({ upstreamStatus: upstream.status, data, spardingProof: proof });
|
|
87
280
|
} catch (err__ANY_TYPE__) {
|
|
88
281
|
spardaRecord(tool, err.status ?? 502, Date.now() - t0);
|
|
89
282
|
spardaEvent('invoke', tool, err.status ?? 502, err.message);
|
|
90
|
-
return res.status(err.status ?? 502).json({ error: err.message });
|
|
283
|
+
return res.status(err.status ?? 502).json({ error: err.message, spardingProof: proof });
|
|
91
284
|
}
|
|
92
285
|
});
|
|
93
286
|
|
|
94
287
|
function spardaRecord(tool__ANY_TYPE__, status__ANY_TYPE__, ms__ANY_TYPE__) {
|
|
95
|
-
const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
|
|
288
|
+
const st = SPARDA_STATS[tool] ?? (SPARDA_STATS[tool] = { calls: 0, errors: 0, clientErrors: 0, totalMs: 0, lastStatus: null, lastTs: null, consecutive5xx: 0 });
|
|
96
289
|
// innate immunity: latency far beyond the learned baseline is an antigen
|
|
97
290
|
if (st.calls >= 5 && ms > Math.max((st.totalMs / st.calls) * 10, 200)) {
|
|
98
291
|
spardaEvent('immune', tool, status, `latency anomaly: ${ms}ms vs ~${Math.round(st.totalMs / st.calls)}ms baseline`);
|
|
99
292
|
}
|
|
100
293
|
st.calls += 1;
|
|
101
294
|
st.totalMs += ms;
|
|
102
|
-
|
|
295
|
+
// 5xx = server failure (feeds the immune system); 4xx = a valid client answer (404 etc), tracked apart so the count doesn't lie
|
|
296
|
+
if (status >= 500) st.errors += 1;
|
|
297
|
+
else if (status >= 400) st.clientErrors += 1;
|
|
103
298
|
st.lastStatus = status;
|
|
104
299
|
st.lastTs = new Date().toISOString();
|
|
105
300
|
if (status >= 500) st.consecutive5xx += 1;
|