vector-mirror 1.0.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,437 @@
1
+ /**
2
+ * tools.js - MCP Tool Definitions and Handlers (transport-agnostic)
3
+ * Vector Mirror v2.0 Phase 2
4
+ *
5
+ * Interface module: Exports { name, config, handler } per tool.
6
+ * BAUPLAN ref: Sektion 5 (Tools), 5.1 (Annotations), 7.3 (Architecture Separation), 9.3 (Descriptions)
7
+ * DEPENDS: pipeline.js, interface/schema.js
8
+ */
9
+ import {
10
+ analyze,
11
+ analyzeErrorStructured,
12
+ arrange,
13
+ bookmark,
14
+ compare,
15
+ getConstraintTypes,
16
+ getStatus,
17
+ init,
18
+ inspect,
19
+ NO_BASELINE_HINT,
20
+ palette,
21
+ runSelftest,
22
+ } from '../pipeline.js';
23
+ // §RELAIS (an internal spec §0/§1) + P2/S1b Ein-Leib: die VOLLSTÄNDIGEN
24
+ // Descriptions sind PROJEKTIONEN des Claims-Registers (claims.js — die eine
25
+ // Quelle; Blöcke 1–3+5 via BLOCKS, Eigenheiten via EIGENHEITEN). tools.js
26
+ // trägt KEINEN Description-Freitext mehr. Drift ist doppelt unmöglich: per
27
+ // Konstruktion (Import) + per Protokoll-Selftest (tests/relais_red/
28
+ // selftest_claims.mjs, S1 Wortidentität / S1b Vollstring-Pin / S2 Deckung).
29
+ import { DESCRIPTIONS } from './claims.js';
30
+ import {
31
+ analyzeInput,
32
+ analyzeOutput,
33
+ arrangeInput,
34
+ arrangeOutput,
35
+ bookmarkInput,
36
+ bookmarkOutput,
37
+ compareInput,
38
+ constraintsInput,
39
+ constraintsOutput,
40
+ inspectInput,
41
+ inspectOutput,
42
+ paletteInput,
43
+ paletteOutput,
44
+ selftestInput,
45
+ selftestOutput,
46
+ statusInput,
47
+ statusOutput,
48
+ } from './schema.js';
49
+
50
+ // ── ERROR RESPONSE HELPERS ──────────────────────────────────
51
+
52
+ /** Schema-conformant error response for analyze/compare (outputSchema: analyzeOutput).
53
+ * §H9 P2: die Hülle kommt aus pipeline.js#analyzeErrorStructured — EINE Quelle
54
+ * statt physischer Kopie. Der frühere Inline-Spiegel trug denselben
55
+ * Selbst-Widerspruch (isError:true + convergence:'SOLVED' + erfundene
56
+ * randomUUID-analysisId); die wahre Form (null/null) lebt jetzt an genau
57
+ * einem Ort und gilt für JS-API- und MCP-Rand identisch.
58
+ * §6 RELAIS: optionales error{code,hint} aus der pipeline-Quelle wird additiv
59
+ * eingebettet (NUR im isError-Pfad präsent; Parity: prose trägt den hint). */
60
+ function analyzeErrorResponse(prose, error) {
61
+ return {
62
+ content: [{ type: 'text', text: prose }],
63
+ structuredContent: {
64
+ ...analyzeErrorStructured(),
65
+ ...(error ? { error } : {}),
66
+ },
67
+ isError: true,
68
+ };
69
+ }
70
+
71
+ /** Schema-conformant error response for inspect (outputSchema: inspectOutput).
72
+ * §P2: scene.suppressed ist in inspectOutput REQUIRED (schema.js) — analog zum
73
+ * analyzeErrorResponse-Muster (iteration.suppressed). Fehlt es, scheitert die
74
+ * SDK-safeParse der structuredContent still. 0 = nichts getrunkt im Error-Pfad. */
75
+ function inspectErrorResponse(prose, error) {
76
+ return {
77
+ content: [{ type: 'text', text: prose }],
78
+ structuredContent: {
79
+ scene: { width: 0, height: 0, grid: '0x0', elements: [], suppressed: 0 },
80
+ ...(error ? { error } : {}),
81
+ },
82
+ isError: true,
83
+ };
84
+ }
85
+
86
+ /** Schema-conformant error response for palette (outputSchema: paletteOutput). */
87
+ function paletteErrorResponse(prose, error) {
88
+ return {
89
+ content: [{ type: 'text', text: prose }],
90
+ structuredContent: { colors: [], ...(error ? { error } : {}) },
91
+ isError: true,
92
+ };
93
+ }
94
+
95
+ /** Schema-conformant error response for bookmark (outputSchema: bookmarkOutput).
96
+ * analysisId ist im Error-Pfad bereits eine validierte UUID (bookmarkInput
97
+ * erzwingt .uuid()), erfüllt also bookmarkOutput.analysisId=.uuid(). */
98
+ function bookmarkErrorResponse(prose, name, analysisId, error) {
99
+ return {
100
+ content: [{ type: 'text', text: prose }],
101
+ structuredContent: {
102
+ name: name ?? '',
103
+ analysisId,
104
+ stored: false,
105
+ bookmarkCount: 0,
106
+ ...(error ? { error } : {}),
107
+ },
108
+ isError: true,
109
+ };
110
+ }
111
+
112
+ // ── TOOL DEFINITIONS (registerTool-compatible) ──────────────
113
+
114
+ /**
115
+ * Cluster 1: ANALYSE — vector_mirror_analyze
116
+ * BAUPLAN 5 Cluster 1 + 5.1 Annotations
117
+ */
118
+ export const analyzeTool = {
119
+ name: 'vector_mirror_analyze',
120
+ config: {
121
+ // §RELAIS 5-Block-Form (an internal spec §1.1): Orientierung · Input-
122
+ // Grammatik · Output-Kernfelder · verifizierte Eigenheiten (Register-
123
+ // Projektion, endet mit P5-Zeile) · Next step. Budget ≤1300 Zeichen.
124
+ // P2/S1b: vollständig aus dem Register komponiert (claims.js BLOCKS).
125
+ description: DESCRIPTIONS.analyze,
126
+ inputSchema: analyzeInput,
127
+ outputSchema: analyzeOutput,
128
+ annotations: {
129
+ readOnlyHint: true,
130
+ destructiveHint: false,
131
+ idempotentHint: true,
132
+ openWorldHint: false,
133
+ },
134
+ },
135
+ handler: async ({ svg, constraints, previousIssueCount }) => {
136
+ const result = await analyze(svg, constraints, previousIssueCount);
137
+ if (!result.structured)
138
+ return analyzeErrorResponse(result.prose, result.error);
139
+ return {
140
+ content: [{ type: 'text', text: result.prose }],
141
+ structuredContent: result.structured,
142
+ };
143
+ },
144
+ };
145
+
146
+ /**
147
+ * Cluster 1: ANALYSE — vector_mirror_compare
148
+ * BAUPLAN 5 Cluster 1 + 5.1 Annotations
149
+ */
150
+ export const compareTool = {
151
+ name: 'vector_mirror_compare',
152
+ config: {
153
+ description: DESCRIPTIONS.compare,
154
+ inputSchema: compareInput,
155
+ outputSchema: analyzeOutput,
156
+ annotations: {
157
+ readOnlyHint: true,
158
+ destructiveHint: false,
159
+ idempotentHint: false,
160
+ openWorldHint: false,
161
+ },
162
+ },
163
+ handler: async ({ svg, constraints, analysisId }) => {
164
+ const result = await compare(svg, constraints, analysisId);
165
+ if (!result.structured)
166
+ return analyzeErrorResponse(result.prose, result.error);
167
+ return {
168
+ content: [{ type: 'text', text: result.prose }],
169
+ structuredContent: result.structured,
170
+ // §H9 K-13bc: der No-Baseline-Pfad liefert jetzt eine ehrliche non-null
171
+ // Hülle (statt null-Sentinel) — der Nutzungsfehler bleibt am MCP-Rand
172
+ // als isError projiziert (exakte Erkennung via NO_BASELINE_HINT, eine
173
+ // Quelle in pipeline.js; MCP-Verhalten wie vor H9).
174
+ ...(result.prose === NO_BASELINE_HINT ? { isError: true } : {}),
175
+ };
176
+ },
177
+ };
178
+
179
+ /**
180
+ * Cluster 1: ANALYSE — vector_mirror_bookmark
181
+ * §1.4 Globale Bookmarks (B-3, O1): Named-Baseline für den Sniper-Loop.
182
+ */
183
+ export const bookmarkTool = {
184
+ name: 'vector_mirror_bookmark',
185
+ config: {
186
+ description: DESCRIPTIONS.bookmark,
187
+ inputSchema: bookmarkInput,
188
+ outputSchema: bookmarkOutput,
189
+ annotations: {
190
+ readOnlyHint: false,
191
+ destructiveHint: false,
192
+ idempotentHint: true,
193
+ openWorldHint: false,
194
+ },
195
+ },
196
+ handler: async ({ name, analysisId }) => {
197
+ const result = bookmark(name, analysisId);
198
+ if (!result.structured)
199
+ return bookmarkErrorResponse(
200
+ result.prose,
201
+ name,
202
+ analysisId,
203
+ result.error,
204
+ );
205
+ return {
206
+ content: [{ type: 'text', text: result.prose }],
207
+ structuredContent: result.structured,
208
+ };
209
+ },
210
+ };
211
+
212
+ /**
213
+ * Cluster 2: INSPEKTION — vector_mirror_inspect
214
+ * BAUPLAN 5 Cluster 2 + 5.1 Annotations
215
+ */
216
+ export const inspectTool = {
217
+ name: 'vector_mirror_inspect',
218
+ config: {
219
+ description: DESCRIPTIONS.inspect,
220
+ inputSchema: inspectInput,
221
+ outputSchema: inspectOutput,
222
+ annotations: {
223
+ readOnlyHint: true,
224
+ destructiveHint: false,
225
+ idempotentHint: true,
226
+ openWorldHint: false,
227
+ },
228
+ },
229
+ handler: async ({ svg }) => {
230
+ const result = await inspect(svg);
231
+ if (!result.structured)
232
+ return inspectErrorResponse(result.prose, result.error);
233
+ return {
234
+ content: [{ type: 'text', text: result.prose }],
235
+ structuredContent: result.structured,
236
+ };
237
+ },
238
+ };
239
+
240
+ /**
241
+ * Cluster 2: INSPEKTION — vector_mirror_palette
242
+ * BAUPLAN 5 Cluster 2 + 5.1 Annotations
243
+ */
244
+ export const paletteTool = {
245
+ name: 'vector_mirror_palette',
246
+ config: {
247
+ description: DESCRIPTIONS.palette,
248
+ inputSchema: paletteInput,
249
+ outputSchema: paletteOutput,
250
+ annotations: {
251
+ readOnlyHint: true,
252
+ destructiveHint: false,
253
+ idempotentHint: true,
254
+ openWorldHint: false,
255
+ },
256
+ },
257
+ handler: async ({ svg }) => {
258
+ const result = await palette(svg);
259
+ if (!result.structured)
260
+ return paletteErrorResponse(result.prose, result.error);
261
+ return {
262
+ content: [{ type: 'text', text: result.prose }],
263
+ structuredContent: result.structured,
264
+ };
265
+ },
266
+ };
267
+
268
+ /**
269
+ * Cluster 4: META — vector_mirror_constraints
270
+ * BAUPLAN 5 Cluster 4 + 5.1 Annotations
271
+ */
272
+ export const constraintsTool = {
273
+ name: 'vector_mirror_constraints',
274
+ config: {
275
+ description: DESCRIPTIONS.constraints,
276
+ inputSchema: constraintsInput,
277
+ outputSchema: constraintsOutput,
278
+ annotations: {
279
+ readOnlyHint: true,
280
+ destructiveHint: false,
281
+ idempotentHint: true,
282
+ openWorldHint: false,
283
+ },
284
+ },
285
+ handler: async () => {
286
+ const result = getConstraintTypes();
287
+ return {
288
+ content: [{ type: 'text', text: result.prose }],
289
+ structuredContent: result.structured,
290
+ };
291
+ },
292
+ };
293
+
294
+ /**
295
+ * Cluster 4: META — vector_mirror_status
296
+ * BAUPLAN 5 Cluster 4 + 5.1 Annotations
297
+ */
298
+ export const statusTool = {
299
+ name: 'vector_mirror_status',
300
+ config: {
301
+ description: DESCRIPTIONS.status,
302
+ inputSchema: statusInput,
303
+ outputSchema: statusOutput,
304
+ annotations: {
305
+ readOnlyHint: true,
306
+ destructiveHint: false,
307
+ idempotentHint: false,
308
+ openWorldHint: false,
309
+ },
310
+ },
311
+ handler: async () => {
312
+ const result = getStatus();
313
+ return {
314
+ content: [{ type: 'text', text: result.prose }],
315
+ structuredContent: result.structured,
316
+ };
317
+ },
318
+ };
319
+
320
+ /**
321
+ * Cluster 3: EDITOR — vector_mirror_arrange
322
+ * BAUPLAN 5 Cluster 3 + 5.1 Annotations
323
+ */
324
+
325
+ /** Schema-conformant error response for arrange (outputSchema: arrangeOutput). */
326
+ function arrangeErrorResponse(prose, error) {
327
+ return {
328
+ content: [{ type: 'text', text: prose }],
329
+ structuredContent: {
330
+ attributes: {},
331
+ warnings: [prose],
332
+ ...(error ? { error } : {}),
333
+ },
334
+ isError: true,
335
+ };
336
+ }
337
+
338
+ export const arrangeTool = {
339
+ name: 'vector_mirror_arrange',
340
+ config: {
341
+ description: DESCRIPTIONS.arrange,
342
+ inputSchema: arrangeInput,
343
+ outputSchema: arrangeOutput,
344
+ annotations: {
345
+ readOnlyHint: true,
346
+ destructiveHint: false,
347
+ idempotentHint: true,
348
+ openWorldHint: false,
349
+ },
350
+ },
351
+ handler: async ({ canvas, elements, constraints }) => {
352
+ try {
353
+ const result = arrange(canvas, elements, constraints);
354
+ // Defense-in-depth: sanitize NaN/Infinity before SDK validation (Audit Fix N)
355
+ const sanitized = JSON.parse(
356
+ JSON.stringify(result.structured, (_, v) =>
357
+ typeof v === 'number' && !Number.isFinite(v) ? 0 : v,
358
+ ),
359
+ );
360
+ return {
361
+ content: [{ type: 'text', text: result.prose }],
362
+ structuredContent: sanitized,
363
+ };
364
+ } catch (err) {
365
+ // §6 RELAIS: der Catch IST der Entstehungs-Ort dieses Fehlers (die
366
+ // pipeline wirft ohne Code) — ARRANGE_FAILED klassifiziert ihn, der
367
+ // hint trägt die geworfene Wahrheit und steht wortidentisch in der Prosa.
368
+ const hint = `Arrange fehlgeschlagen: ${err.message}`;
369
+ return arrangeErrorResponse(hint, { code: 'ARRANGE_FAILED', hint });
370
+ }
371
+ },
372
+ };
373
+
374
+ /**
375
+ * Cluster 4: META — vector_mirror_selftest (§1.9 Eichkörper-Selftest)
376
+ * 9. registriertes Tool. MISST nur (REGEL-3/9): läuft die 5 Kalibrierungs-
377
+ * Eichkörper (EK-1..5) und PARTIAL-matcht gegen die anti-zirk aus der Spec
378
+ * abgeleiteten expected-Felder (Grid/Farbe/Reliability-Wahrheit), NIE gegen
379
+ * gespeicherten Tool-Output (Anti-Zirkularität REGEL-2). Read-only, idempotent.
380
+ */
381
+ export const selftestTool = {
382
+ name: 'vector_mirror_selftest',
383
+ config: {
384
+ description: DESCRIPTIONS.selftest,
385
+ inputSchema: selftestInput,
386
+ outputSchema: selftestOutput,
387
+ annotations: {
388
+ readOnlyHint: true,
389
+ destructiveHint: false,
390
+ idempotentHint: true,
391
+ openWorldHint: false,
392
+ },
393
+ },
394
+ handler: async ({ full }) => {
395
+ const result = await runSelftest(full);
396
+ return {
397
+ content: [{ type: 'text', text: result.prose }],
398
+ structuredContent: result.structured,
399
+ };
400
+ },
401
+ };
402
+
403
+ // ── EXPORTS ─────────────────────────────────────────────────
404
+
405
+ /** All tools as array for server.js registration loop. */
406
+ export const tools = [
407
+ analyzeTool,
408
+ compareTool,
409
+ bookmarkTool,
410
+ inspectTool,
411
+ paletteTool,
412
+ arrangeTool,
413
+ constraintsTool,
414
+ statusTool,
415
+ selftestTool,
416
+ ];
417
+
418
+ /**
419
+ * Legacy handler dispatcher (Phase 1 compatibility).
420
+ * Phase 2 uses server.js with registerTool() directly.
421
+ */
422
+ export async function handleTool(name, args) {
423
+ await init();
424
+
425
+ try {
426
+ const tool = tools.find((t) => t.name === name);
427
+ if (!tool)
428
+ return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }] };
429
+ return await tool.handler(args);
430
+ } catch (err) {
431
+ return {
432
+ content: [
433
+ { type: 'text', text: `Vector Mirror Systemfehler: ${err.message}` },
434
+ ],
435
+ };
436
+ }
437
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * bbox.js - Bounding-Box Mathematics
3
+ * Extracted from mirror.js:160-162
4
+ * Vector Mirror v2.0
5
+ */
6
+
7
+ /**
8
+ * Returns true if two bounding boxes overlap.
9
+ */
10
+ export function bboxOverlap(a, b) {
11
+ return !(
12
+ a.x + a.w <= b.x ||
13
+ b.x + b.w <= a.x ||
14
+ a.y + a.h <= b.y ||
15
+ b.y + b.h <= a.y
16
+ );
17
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * breaker.js — Circuit-Breaker via Opossum 9.x (P1-03, VM-SM-002)
3
+ * Vector Mirror v2.5
4
+ *
5
+ * Schützt den Playwright-Browser vor "Permanent-Bricking" (VM-SM-002):
6
+ * Browser-Crashes oder Hangs öffnen den Breaker, Recovery erfolgt nach
7
+ * `resetTimeout` durch einen Liveness-Probe. User-Eingabefehler (ungültiges
8
+ * SVG, Size-Limit, Security-Violation) zählen NICHT gegen den Breaker —
9
+ * sie sind keine Browser-Fehler.
10
+ *
11
+ * Source-of-truth references:
12
+ * - FIX_PLAN_2026-04-18 §1.2 P1-03 (revidiert via RB-11)
13
+ * - RB-11 §6 Option A (Opossum 9.x adoptiert)
14
+ * - RB-12 — BP/SOTA-Validation 2026-04-26 (Library-Source-of-Truth)
15
+ * - ADR-030 (Eigenständigkeit) — Präzedenzfall in P1-03 dokumentiert
16
+ * - KATALOG VM-SM-002 (Permanent-Bricking)
17
+ * - Reproducer GEMINI-B2 (Browser-Crash-Recovery)
18
+ *
19
+ * Hexagonal-Vertrag: Lib module: keine Imports aus adapters/ oder interface/.
20
+ * `resolve` wird per Dependency-Injection an `createRenderOnce` übergeben.
21
+ *
22
+ * Opossum-Issue #564 Pitfall (relevant für errorFilter):
23
+ * Wenn die Action einen Error WIRFT, dessen Code von errorFilter mit `true`
24
+ * markiert wird, RESOLVED Opossum die `fire()`-Promise mit dem Error-Objekt
25
+ * als Wert (statt zu rejecten). USER-Fehler dürfen daher NIE geworfen werden,
26
+ * nur als Return-Object durchgereicht. `renderOnce` hält diesen Vertrag.
27
+ */
28
+
29
+ import CircuitBreaker from 'opossum';
30
+
31
+ /**
32
+ * Error-Codes aus `playwright.resolve()`, die User-Input-Fehler darstellen.
33
+ * Diese werden als reguläre Returns durchgereicht und zählen NICHT gegen
34
+ * den Breaker (der Browser ist gesund — der Input ist das Problem).
35
+ * @type {ReadonlySet<string>}
36
+ */
37
+ export const USER_ERROR_CODES = Object.freeze(
38
+ new Set([
39
+ 'INVALID_INPUT',
40
+ 'SECURITY_VIOLATION',
41
+ 'SVG_TOO_LARGE',
42
+ 'NO_SVG_FOUND',
43
+ 'EMPTY_SVG',
44
+ 'TOO_MANY_ELEMENTS',
45
+ 'NO_ELEMENTS',
46
+ ]),
47
+ );
48
+
49
+ /**
50
+ * Error-Codes, die einen Browser-Defekt anzeigen (page.setContent timeout,
51
+ * page.evaluate-Crash). Diese werden als geworfene Fehler weitergereicht,
52
+ * damit der Breaker sie zählen kann.
53
+ * @type {ReadonlySet<string>}
54
+ */
55
+ export const BROWSER_ERROR_CODES = Object.freeze(new Set(['LOAD_FAILED']));
56
+
57
+ /**
58
+ * Erzeugt eine `renderOnce`-Funktion, die einen Browser-Wrapper für `resolve()`
59
+ * darstellt: macht aus Browser-Fehler-Returns geworfene Errors mit `err.code`
60
+ * + `err.kind = 'BROWSER'`. User-Fehler bleiben als Returns durchgereicht.
61
+ *
62
+ * Hexagonal-DI: `resolveFn` wird injiziert (`pipeline.js` importiert sowohl
63
+ * `createRenderOnce` aus breaker.js als auch `resolve` aus playwright.js und
64
+ * verkabelt sie). breaker.js bleibt frei von adapters/-Imports.
65
+ *
66
+ * @param {Function} resolveFn z.B. `playwright.resolve` (page, svgString) → Result
67
+ * @returns {Function} (page, svgString) → Promise<Result>
68
+ */
69
+ export function createRenderOnce(resolveFn) {
70
+ if (typeof resolveFn !== 'function') {
71
+ throw new TypeError('createRenderOnce: resolveFn muss Function sein');
72
+ }
73
+ return async function renderOnce(page, svgString) {
74
+ if (!page) {
75
+ const err = new Error('Playwright-Page nicht initialisiert');
76
+ err.code = 'NO_PAGE';
77
+ err.kind = 'BROWSER';
78
+ throw err;
79
+ }
80
+ const result = await resolveFn(page, svgString);
81
+ if (
82
+ result &&
83
+ typeof result === 'object' &&
84
+ result.error &&
85
+ BROWSER_ERROR_CODES.has(result.error)
86
+ ) {
87
+ const err = new Error(result.message || result.error);
88
+ err.code = result.error;
89
+ err.kind = 'BROWSER';
90
+ throw err;
91
+ }
92
+ return result;
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Default-Optionen gemäß FIX_PLAN P1-03 + 1xAPI 2026 BP-Defaults
98
+ * (RB-12 §2.1 Quellenabgleich, identisch zu 1xAPI-Snippet).
99
+ * Tests können via `createBreaker(action, { ... })` überschreiben (z.B.
100
+ * `resetTimeout: 50` für schnelle State-Transitions).
101
+ */
102
+ export const DEFAULT_BREAKER_OPTS = Object.freeze({
103
+ timeout: 5000, // per-call abort
104
+ errorThresholdPercentage: 50, // 50% Fail-Rate öffnet
105
+ resetTimeout: 30000, // half-open nach 30s
106
+ volumeThreshold: 5, // min 5 Fires vor Trip
107
+ rollingCountTimeout: 10000, // 10s sliding window
108
+ // §1.7 P5 Bulkhead: Singleton-Browser → capacity:1. Parallele Renders
109
+ // konkurrieren sonst um dieselbe Page (pageMutex in playwright.js
110
+ // serialisiert bereits setContent+evaluate; capacity:1 ist die
111
+ // Breaker-Ebene-Defense-in-Depth). Der 2. konkurrente fire wird mit
112
+ // ESEMLOCKED abgewiesen (fireResolve mappt das auf 'Concurrency-Limit
113
+ // erreicht'). Opossum-Semaphore (circuit.js Semaphore(capacity)).
114
+ capacity: 1,
115
+ name: 'render', // Multi-Breaker-Observability
116
+ });
117
+
118
+ /**
119
+ * Default `errorFilter`: USER-Fehler (Input-Probleme) zählen nicht.
120
+ * Browser-Fehler (`kind === 'BROWSER'`) zählen.
121
+ *
122
+ * Opossum-Konvention (RB-12 §2.2): `errorFilter(err) === true` ⇒ Fehler
123
+ * wird IGNORIERT (failure-stat nicht inkrementiert).
124
+ *
125
+ * VORSICHT (Issue #564): Diese Funktion wird nur ausgewertet, wenn die
126
+ * Action einen Error WIRFT. Ein gefilterter Error führt dann dazu, dass
127
+ * `fire()` mit dem Error-Objekt RESOLVED. Daher: USER-Codes dürfen niemals
128
+ * geworfen werden — `renderOnce` hält diesen Vertrag.
129
+ *
130
+ * @param {Error & {code?: string, kind?: string}} err
131
+ * @returns {boolean} true → ignorieren, false → zählen
132
+ */
133
+ export function defaultErrorFilter(err) {
134
+ if (!err) return false;
135
+ if (err.kind === 'BROWSER') return false;
136
+ if (err.code && USER_ERROR_CODES.has(err.code)) return true;
137
+ return false;
138
+ }
139
+
140
+ /**
141
+ * Erzeugt einen Circuit-Breaker für die übergebene Action.
142
+ * Die Action wird i.d.R. via `createRenderOnce(resolveFn)` erzeugt.
143
+ *
144
+ * @param {Function} action z.B. das Resultat von `createRenderOnce(resolve)`
145
+ * @param {object} [opts] Override gegen DEFAULT_BREAKER_OPTS
146
+ * @returns {CircuitBreaker}
147
+ */
148
+ export function createBreaker(action, opts = {}) {
149
+ if (typeof action !== 'function') {
150
+ throw new TypeError('createBreaker: action muss Function sein');
151
+ }
152
+ const finalOpts = {
153
+ ...DEFAULT_BREAKER_OPTS,
154
+ errorFilter: defaultErrorFilter,
155
+ ...opts,
156
+ };
157
+ return new CircuitBreaker(action, finalOpts);
158
+ }
159
+
160
+ /**
161
+ * Re-Export von `CircuitBreaker.isOurError` für Pipeline-Integration:
162
+ * unterscheidet CB-eigene Rejections (EOPENBREAKER, ETIMEDOUT, ESHUTDOWN,
163
+ * ESEMLOCKED) von Action-Errors. Hilft, structuredContent-Codes korrekt zu
164
+ * mappen (Browser-Defekt vs. Breaker-Schutz).
165
+ *
166
+ * @param {Error} err
167
+ * @returns {boolean} true wenn Error vom Breaker selbst stammt
168
+ */
169
+ export const isOurError = CircuitBreaker.isOurError;
170
+
171
+ /**
172
+ * Liveness-Probe: Race zwischen `page.evaluate(() => 1)` und einem Timeout.
173
+ * Wird in pipeline.init() nach createResolver() aufgerufen, um zu erkennen,
174
+ * ob die Seite wirklich nutzbar ist (Crash-Detection vor erstem Render).
175
+ *
176
+ * Optional kann die Pipeline zusätzlich `breaker.healthCheck(() =>
177
+ * livenessPing(page), 30000)` als Hintergrund-Watchdog verkabeln (Opossum
178
+ * öffnet dann den Breaker bei Reject automatisch). RB-12 §2.4.
179
+ *
180
+ * @param {object} page
181
+ * @param {number} [timeoutMs=1000]
182
+ * @returns {Promise<boolean>} true bei healthy, wirft sonst
183
+ */
184
+ export async function livenessPing(page, timeoutMs = 1000) {
185
+ if (!page) {
186
+ const err = new Error('Playwright-Page nicht initialisiert');
187
+ err.code = 'NO_PAGE';
188
+ throw err;
189
+ }
190
+ let timer;
191
+ const timeoutP = new Promise((_, rej) => {
192
+ timer = setTimeout(() => {
193
+ const err = new Error(`Liveness-Ping Timeout nach ${timeoutMs}ms`);
194
+ err.code = 'LIVENESS_TIMEOUT';
195
+ rej(err);
196
+ }, timeoutMs);
197
+ });
198
+ try {
199
+ const value = await Promise.race([page.evaluate(() => 1), timeoutP]);
200
+ return value === 1;
201
+ } finally {
202
+ clearTimeout(timer);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Komprimiert breaker.stats für `vector_mirror_status` structured-Output.
208
+ * Subset gewählt: 6 Counter + name + semaphoreRejections + latencyMean.
209
+ * RB-12 §2.3 Quellenabgleich.
210
+ *
211
+ * @param {CircuitBreaker} breaker
212
+ * @returns {{
213
+ * name: string,
214
+ * state: 'open'|'half-open'|'closed',
215
+ * fires: number, successes: number, failures: number,
216
+ * timeouts: number, rejects: number, fallbacks: number,
217
+ * semaphoreRejections: number, latencyMean: number
218
+ * }}
219
+ */
220
+ export function getBreakerStats(breaker) {
221
+ if (!breaker) {
222
+ throw new TypeError('getBreakerStats: breaker required');
223
+ }
224
+ let state = 'closed';
225
+ if (breaker.opened) state = 'open';
226
+ else if (breaker.halfOpen) state = 'half-open';
227
+ const s = breaker.stats;
228
+ return {
229
+ name: breaker.name,
230
+ state,
231
+ fires: s.fires,
232
+ successes: s.successes,
233
+ failures: s.failures,
234
+ timeouts: s.timeouts,
235
+ rejects: s.rejects,
236
+ fallbacks: s.fallbacks,
237
+ semaphoreRejections: s.semaphoreRejections,
238
+ latencyMean: s.latencyMean,
239
+ };
240
+ }