@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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 +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates the isolated-engine child entrypoint (#307).
|
|
3
|
+
*
|
|
4
|
+
* This is its own module, reached by dynamic import, for one reason:
|
|
5
|
+
* `import.meta` is a **parse error** under this project's Jest setup,
|
|
6
|
+
* which runs the codebase as CommonJS (there is no
|
|
7
|
+
* `--experimental-vm-modules`). Any module that so much as mentions
|
|
8
|
+
* `import.meta` becomes unimportable from a test, and the runner has to
|
|
9
|
+
* stay testable. Tests pass an explicit child path, so they never load
|
|
10
|
+
* this file. Same reason `scripts/` is the only other place using it.
|
|
11
|
+
*/
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the child entrypoint as a sibling of this module, matching the
|
|
16
|
+
* extension we are ourselves running as: `.js` from `dist/`, `.ts` under
|
|
17
|
+
* `tsx`.
|
|
18
|
+
*
|
|
19
|
+
* @returns Absolute path to the child, and whether it needs a TS loader
|
|
20
|
+
*/
|
|
21
|
+
export function resolveChildEntrypoint() {
|
|
22
|
+
const here = fileURLToPath(import.meta.url);
|
|
23
|
+
const needsTsx = here.endsWith('.ts');
|
|
24
|
+
return {
|
|
25
|
+
childPath: path.join(path.dirname(here), needsTsx ? 'engineChild.ts' : 'engineChild.js'),
|
|
26
|
+
needsTsx,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors that carry their own category (#382).
|
|
3
|
+
*
|
|
4
|
+
* `categorizeError` decides retryability by matching phrases in the
|
|
5
|
+
* message — `invalid`, `must be`, `not found`, `timed out`. Anything it
|
|
6
|
+
* does not recognise falls through to `internal`, which is not
|
|
7
|
+
* retryable and reads to an agent as "the server is broken".
|
|
8
|
+
*
|
|
9
|
+
* An audit of every `throw new Error(...)` in `src/` put 47 messages in
|
|
10
|
+
* that bucket, and at least eleven of them were plainly the caller's
|
|
11
|
+
* input:
|
|
12
|
+
*
|
|
13
|
+
* Steps cannot exceed 256, got 512
|
|
14
|
+
* Hits (9) cannot exceed steps (8)
|
|
15
|
+
* Number of sounds must match number of patterns
|
|
16
|
+
* MIDI input too large (900000 > 512000 bytes)
|
|
17
|
+
*
|
|
18
|
+
* None contains a phrase the matcher looks for, and the difference
|
|
19
|
+
* decides whether an agent edits its argument or gives up.
|
|
20
|
+
*
|
|
21
|
+
* Growing the matcher is the treadmill this replaces: "cannot exceed"
|
|
22
|
+
* would be the fourth phrase patched in after the fact, after the
|
|
23
|
+
* `'timed out'` vs `'timeout'` fix and the rate-limit class of #380.
|
|
24
|
+
* A thrower that knows what kind of failure it has should say so.
|
|
25
|
+
*/
|
|
26
|
+
/** The caller passed something that cannot be accepted. Not retryable; change the argument. */
|
|
27
|
+
export class ValidationError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'ValidationError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The request is well-formed but the system is not in a state to serve
|
|
35
|
+
* it, and the message names the action that would fix that — create a
|
|
36
|
+
* session, destroy one, run init.
|
|
37
|
+
*/
|
|
38
|
+
export class BusinessError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'BusinessError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A failure that may not recur: the model returned something
|
|
46
|
+
* unparseable, a network hiccup, a service that was briefly unwell.
|
|
47
|
+
*
|
|
48
|
+
* The distinction that matters to a caller is whether trying again could
|
|
49
|
+
* help. `No JSON found in response` means the model produced prose where
|
|
50
|
+
* a JSON block was asked for — the next attempt often does not, and the
|
|
51
|
+
* message contains no word the phrase matcher recognises, so it landed
|
|
52
|
+
* in `internal` and told the caller not to bother.
|
|
53
|
+
*/
|
|
54
|
+
export class TransientError extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'TransientError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -6,7 +6,51 @@ import { Logger } from './Logger.js';
|
|
|
6
6
|
export class ErrorRecovery {
|
|
7
7
|
logger;
|
|
8
8
|
errorHistory = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Failures that a retry or fallback went on to rescue.
|
|
11
|
+
*
|
|
12
|
+
* Kept apart from `errorHistory` because success calls
|
|
13
|
+
* clearErrorHistory, which used to erase exactly the case an operator
|
|
14
|
+
* most wants to see — "writes are flaky but recovering on retry 2".
|
|
15
|
+
* Measured before this: a run that failed once then succeeded
|
|
16
|
+
* reported `{}`, indistinguishable from a run with no trouble at all
|
|
17
|
+
* (#286).
|
|
18
|
+
*/
|
|
19
|
+
recoveredHistory = new Map();
|
|
20
|
+
/**
|
|
21
|
+
* Operations that actually feed these statistics. Only
|
|
22
|
+
* handlePatternWrite routes through executeWithRetry today, so an
|
|
23
|
+
* empty map meant "nothing instrumented reported trouble", not "the
|
|
24
|
+
* system is healthy" — and the Record<string, …> shape implied
|
|
25
|
+
* coverage that did not exist. Reported explicitly so a reader can
|
|
26
|
+
* tell the two apart (#286).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Operations `getErrorStats` seeds with an explicit zero row.
|
|
30
|
+
*
|
|
31
|
+
* This named 'Pattern Write' — which nothing calls: its only path,
|
|
32
|
+
* `handlePatternWrite`, is reached solely from
|
|
33
|
+
* `writePatternWithValidation`, and nothing in `src/server` calls
|
|
34
|
+
* that. So diagnostics carried a permanent all-zero row for dead code
|
|
35
|
+
* while omitting the one operation that does run (#445).
|
|
36
|
+
*
|
|
37
|
+
* 'Browser Init' is the live one, from `StrudelController.ts:199`
|
|
38
|
+
* (#315). A zero row for it is the useful kind: it says the retry
|
|
39
|
+
* path exists and has not fired, which is exactly what an operator
|
|
40
|
+
* wants to know before a failure rather than after.
|
|
41
|
+
*/
|
|
42
|
+
static INSTRUMENTED_OPERATIONS = ['Browser Init'];
|
|
9
43
|
ERROR_WINDOW = 60000; // 1 minute window for error tracking
|
|
44
|
+
/**
|
|
45
|
+
* Operations this instance has handled, successfully or not.
|
|
46
|
+
*
|
|
47
|
+
* The seed list above says which paths EXIST; this says which have
|
|
48
|
+
* RUN. A clean run has no error and no recovery to record, so without
|
|
49
|
+
* it a successful operation left no row and looked identical to one
|
|
50
|
+
* nothing ever watched — the ambiguity #288 removed for the seeded
|
|
51
|
+
* names and left everywhere else.
|
|
52
|
+
*/
|
|
53
|
+
seenOperations = new Set();
|
|
10
54
|
constructor() {
|
|
11
55
|
this.logger = new Logger();
|
|
12
56
|
}
|
|
@@ -17,18 +61,47 @@ export class ErrorRecovery {
|
|
|
17
61
|
* @param strategy - Recovery strategy configuration
|
|
18
62
|
* @returns Result of the operation
|
|
19
63
|
*/
|
|
64
|
+
/**
|
|
65
|
+
* Note on reachability: the only production entry point into this
|
|
66
|
+
* class is `handlePatternWrite`, which calls this. `executeWithRetry`
|
|
67
|
+
* and `clearErrorHistory` are public because they are the seam the
|
|
68
|
+
* retry tests drive directly, not because anything else calls them.
|
|
69
|
+
*
|
|
70
|
+
* Seven methods that nothing called at all — handleBrowserInit,
|
|
71
|
+
* handleNetworkOperation, createCircuitBreaker, isFrequentlyFailing,
|
|
72
|
+
* executeWithTimeout, executeWithRetryAndTimeout and
|
|
73
|
+
* clearAllErrorHistory — were removed in #309. They were fully tested,
|
|
74
|
+
* which is how the file reported 100% coverage while most of it was
|
|
75
|
+
* unreachable, and how the docs could describe an `ErrorRecovery.withRetry`
|
|
76
|
+
* that never existed without anything noticing.
|
|
77
|
+
*
|
|
78
|
+
* Browser init and strudel.cc requests still have no retry. That gap
|
|
79
|
+
* is real and is tracked separately: reviving deleted machinery
|
|
80
|
+
* deserves the scrutiny of new code, not a restore from history.
|
|
81
|
+
*/
|
|
20
82
|
async executeWithRetry(operation, operationName, strategy = {
|
|
21
83
|
maxRetries: 3,
|
|
22
84
|
retryDelay: 1000,
|
|
23
85
|
exponentialBackoff: true
|
|
24
86
|
}) {
|
|
87
|
+
// Recorded on entry: an operation that ran and succeeded should
|
|
88
|
+
// report an explicit zero rather than no row at all (#288's
|
|
89
|
+
// distinction, which only the seeded names used to get).
|
|
90
|
+
this.seenOperations.add(operationName);
|
|
25
91
|
let lastError = null;
|
|
26
92
|
for (let attempt = 0; attempt <= strategy.maxRetries; attempt++) {
|
|
27
93
|
try {
|
|
28
94
|
this.logger.debug(`Executing ${operationName} (attempt ${attempt + 1}/${strategy.maxRetries + 1})`);
|
|
29
95
|
const result = await operation();
|
|
30
|
-
// Success
|
|
31
|
-
|
|
96
|
+
// Success. The failures that preceded it still happened, so
|
|
97
|
+
// move them to the recovered log rather than dropping them —
|
|
98
|
+
// dropping them is what made recovered flakiness invisible
|
|
99
|
+
// (#286). recordRecovery clears the outstanding bucket itself.
|
|
100
|
+
this.recordRecovery(operationName);
|
|
101
|
+
// The error bucket only — not `clearErrorHistory`, which also
|
|
102
|
+
// forgets that the operation ran and would make every success
|
|
103
|
+
// erase its own zero row (#445).
|
|
104
|
+
this.errorHistory.delete(operationName);
|
|
32
105
|
return result;
|
|
33
106
|
}
|
|
34
107
|
catch (error) {
|
|
@@ -37,9 +110,13 @@ export class ErrorRecovery {
|
|
|
37
110
|
this.logger.warn(`${operationName} failed (attempt ${attempt + 1}/${strategy.maxRetries + 1})`, { error: error.message });
|
|
38
111
|
// Don't retry on last attempt
|
|
39
112
|
if (attempt < strategy.maxRetries) {
|
|
40
|
-
const
|
|
113
|
+
const base = strategy.exponentialBackoff
|
|
41
114
|
? strategy.retryDelay * Math.pow(2, attempt)
|
|
42
115
|
: strategy.retryDelay;
|
|
116
|
+
// Full-jitter-lite: half the computed delay plus a random half.
|
|
117
|
+
const delay = strategy.jitter
|
|
118
|
+
? Math.round(base * (0.5 + Math.random() * 0.5))
|
|
119
|
+
: base;
|
|
43
120
|
this.logger.debug(`Waiting ${delay}ms before retry`);
|
|
44
121
|
await this.sleep(delay);
|
|
45
122
|
}
|
|
@@ -51,7 +128,10 @@ export class ErrorRecovery {
|
|
|
51
128
|
if (strategy.fallbackAction) {
|
|
52
129
|
this.logger.info(`Executing fallback for ${operationName}`);
|
|
53
130
|
try {
|
|
54
|
-
|
|
131
|
+
const fallbackResult = await strategy.fallbackAction();
|
|
132
|
+
// The fallback rescued it, so these failures are recovered too.
|
|
133
|
+
this.recordRecovery(operationName);
|
|
134
|
+
return fallbackResult;
|
|
55
135
|
}
|
|
56
136
|
catch (fallbackError) {
|
|
57
137
|
this.logger.error(`Fallback failed for ${operationName}`, fallbackError);
|
|
@@ -59,47 +139,6 @@ export class ErrorRecovery {
|
|
|
59
139
|
}
|
|
60
140
|
throw new Error(`${operationName} failed after ${strategy.maxRetries + 1} attempts: ${lastError?.message}`);
|
|
61
141
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Wraps an operation with timeout protection
|
|
64
|
-
* @param operation - Async function to execute
|
|
65
|
-
* @param timeout - Timeout in milliseconds
|
|
66
|
-
* @param operationName - Name for error messages
|
|
67
|
-
* @returns Result of the operation
|
|
68
|
-
*/
|
|
69
|
-
async executeWithTimeout(operation, timeout, operationName) {
|
|
70
|
-
return Promise.race([
|
|
71
|
-
operation(),
|
|
72
|
-
this.createTimeout(timeout, operationName)
|
|
73
|
-
]);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Combines retry logic with timeout protection
|
|
77
|
-
* @param operation - Async function to execute
|
|
78
|
-
* @param operationName - Name for logging
|
|
79
|
-
* @param timeout - Timeout per attempt in milliseconds
|
|
80
|
-
* @param strategy - Recovery strategy
|
|
81
|
-
* @returns Result of the operation
|
|
82
|
-
*/
|
|
83
|
-
async executeWithRetryAndTimeout(operation, operationName, timeout, strategy) {
|
|
84
|
-
return this.executeWithRetry(() => this.executeWithTimeout(operation, timeout, operationName), operationName, strategy);
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Gracefully handles browser initialization errors
|
|
88
|
-
* @param initFunction - Browser initialization function
|
|
89
|
-
* @returns Initialization result
|
|
90
|
-
*/
|
|
91
|
-
async handleBrowserInit(initFunction) {
|
|
92
|
-
return this.executeWithRetryAndTimeout(initFunction, 'Browser Initialization', 30000, // 30 second timeout
|
|
93
|
-
{
|
|
94
|
-
maxRetries: 2,
|
|
95
|
-
retryDelay: 2000,
|
|
96
|
-
exponentialBackoff: true,
|
|
97
|
-
fallbackAction: async () => {
|
|
98
|
-
this.logger.warn('Browser initialization failed, suggesting headless mode');
|
|
99
|
-
return 'Browser initialization failed. Try setting headless:true in config.json';
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
142
|
/**
|
|
104
143
|
* Handles pattern write errors with validation
|
|
105
144
|
* @param writeFunction - Pattern write function
|
|
@@ -119,19 +158,6 @@ export class ErrorRecovery {
|
|
|
119
158
|
}
|
|
120
159
|
});
|
|
121
160
|
}
|
|
122
|
-
/**
|
|
123
|
-
* Checks if an operation is experiencing repeated failures
|
|
124
|
-
* @param operationName - Name of operation to check
|
|
125
|
-
* @param threshold - Number of errors to consider "frequent"
|
|
126
|
-
* @returns True if operation is failing frequently
|
|
127
|
-
*/
|
|
128
|
-
isFrequentlyFailing(operationName, threshold = 3) {
|
|
129
|
-
const errors = this.errorHistory.get(operationName) || [];
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
// Count errors within the time window
|
|
132
|
-
const recentErrors = errors.filter(timestamp => now - timestamp < this.ERROR_WINDOW);
|
|
133
|
-
return recentErrors.length >= threshold;
|
|
134
|
-
}
|
|
135
161
|
/**
|
|
136
162
|
* Gets error statistics for monitoring
|
|
137
163
|
* @returns Error statistics by operation
|
|
@@ -139,13 +165,26 @@ export class ErrorRecovery {
|
|
|
139
165
|
getErrorStats() {
|
|
140
166
|
const stats = {};
|
|
141
167
|
const now = Date.now();
|
|
142
|
-
|
|
143
|
-
|
|
168
|
+
const recent = (ts) => ts.filter(t => now - t < this.ERROR_WINDOW);
|
|
169
|
+
// Seed with every instrumented operation so an all-zero row is
|
|
170
|
+
// distinguishable from an operation nothing ever watched. `{}` used
|
|
171
|
+
// to mean both.
|
|
172
|
+
const operations = new Set([
|
|
173
|
+
...ErrorRecovery.INSTRUMENTED_OPERATIONS,
|
|
174
|
+
...this.seenOperations,
|
|
175
|
+
...this.errorHistory.keys(),
|
|
176
|
+
...this.recoveredHistory.keys(),
|
|
177
|
+
]);
|
|
178
|
+
for (const operation of operations) {
|
|
179
|
+
const failed = recent(this.errorHistory.get(operation) ?? []);
|
|
180
|
+
const rescued = recent(this.recoveredHistory.get(operation) ?? []);
|
|
144
181
|
stats[operation] = {
|
|
145
|
-
count:
|
|
146
|
-
lastError:
|
|
182
|
+
count: failed.length,
|
|
183
|
+
lastError: failed.length > 0 ? new Date(Math.max(...failed)) : null,
|
|
184
|
+
recovered: rescued.length,
|
|
185
|
+
lastRecovery: rescued.length > 0 ? new Date(Math.max(...rescued)) : null,
|
|
147
186
|
};
|
|
148
|
-
}
|
|
187
|
+
}
|
|
149
188
|
return stats;
|
|
150
189
|
}
|
|
151
190
|
/**
|
|
@@ -153,13 +192,38 @@ export class ErrorRecovery {
|
|
|
153
192
|
* @param operationName - Operation to clear
|
|
154
193
|
*/
|
|
155
194
|
clearErrorHistory(operationName) {
|
|
195
|
+
// Forgotten entirely, including that it ever ran — "clear this
|
|
196
|
+
// operation's history" that left a zero row behind would be a
|
|
197
|
+
// different promise than this method's name makes.
|
|
198
|
+
//
|
|
199
|
+
// The success path inside `executeWithRetry` does NOT use this: it
|
|
200
|
+
// clears the error bucket directly, because an operation that just
|
|
201
|
+
// succeeded has run and should report an explicit zero rather than
|
|
202
|
+
// vanish (#288, #445).
|
|
203
|
+
this.seenOperations.delete(operationName);
|
|
156
204
|
this.errorHistory.delete(operationName);
|
|
157
205
|
}
|
|
158
206
|
/**
|
|
159
|
-
*
|
|
207
|
+
* Moves an operation's recorded failures into the recovered log.
|
|
208
|
+
*
|
|
209
|
+
* Called when a retry or fallback rescues the operation, so the
|
|
210
|
+
* failures survive the clearErrorHistory that follows.
|
|
211
|
+
*
|
|
212
|
+
* @param operationName - Name of the operation
|
|
160
213
|
*/
|
|
161
|
-
|
|
162
|
-
this.errorHistory.
|
|
214
|
+
recordRecovery(operationName) {
|
|
215
|
+
const failures = this.errorHistory.get(operationName);
|
|
216
|
+
if (!failures || failures.length === 0)
|
|
217
|
+
return;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const existing = this.recoveredHistory.get(operationName) ?? [];
|
|
220
|
+
const merged = [...existing, ...failures]
|
|
221
|
+
.filter(ts => now - ts < this.ERROR_WINDOW);
|
|
222
|
+
this.recoveredHistory.set(operationName, merged);
|
|
223
|
+
// A move, not a copy: a failure belongs in exactly one bucket, or
|
|
224
|
+
// the fallback path reports the same three failures as both
|
|
225
|
+
// outstanding and rescued.
|
|
226
|
+
this.errorHistory.delete(operationName);
|
|
163
227
|
}
|
|
164
228
|
/**
|
|
165
229
|
* Records an error occurrence
|
|
@@ -173,19 +237,6 @@ export class ErrorRecovery {
|
|
|
173
237
|
const recentErrors = errors.filter(ts => now - ts < this.ERROR_WINDOW);
|
|
174
238
|
this.errorHistory.set(operationName, recentErrors);
|
|
175
239
|
}
|
|
176
|
-
/**
|
|
177
|
-
* Creates a timeout promise
|
|
178
|
-
* @param ms - Timeout in milliseconds
|
|
179
|
-
* @param operationName - Name for error message
|
|
180
|
-
* @returns Promise that rejects after timeout
|
|
181
|
-
*/
|
|
182
|
-
createTimeout(ms, operationName) {
|
|
183
|
-
return new Promise((_, reject) => {
|
|
184
|
-
setTimeout(() => {
|
|
185
|
-
reject(new Error(`${operationName} timed out after ${ms}ms`));
|
|
186
|
-
}, ms);
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
240
|
/**
|
|
190
241
|
* Sleep utility
|
|
191
242
|
* @param ms - Milliseconds to sleep
|
|
@@ -214,35 +265,4 @@ export class ErrorRecovery {
|
|
|
214
265
|
});
|
|
215
266
|
return simplified;
|
|
216
267
|
}
|
|
217
|
-
/**
|
|
218
|
-
* Handles network-related errors
|
|
219
|
-
* @param operation - Operation that may fail due to network
|
|
220
|
-
* @param operationName - Name for logging
|
|
221
|
-
* @returns Operation result
|
|
222
|
-
*/
|
|
223
|
-
async handleNetworkOperation(operation, operationName) {
|
|
224
|
-
return this.executeWithRetry(operation, operationName, {
|
|
225
|
-
maxRetries: 5,
|
|
226
|
-
retryDelay: 2000,
|
|
227
|
-
exponentialBackoff: true,
|
|
228
|
-
fallbackAction: async () => {
|
|
229
|
-
throw new Error(`Network operation ${operationName} failed - check internet connection`);
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Creates a circuit breaker for operations that fail frequently
|
|
235
|
-
* @param operation - Operation to protect
|
|
236
|
-
* @param operationName - Name for tracking
|
|
237
|
-
* @param threshold - Number of failures before circuit opens
|
|
238
|
-
* @returns Protected operation
|
|
239
|
-
*/
|
|
240
|
-
createCircuitBreaker(operation, operationName, threshold = 5) {
|
|
241
|
-
return async () => {
|
|
242
|
-
if (this.isFrequentlyFailing(operationName, threshold)) {
|
|
243
|
-
throw new Error(`Circuit breaker open for ${operationName} - operation disabled due to repeated failures`);
|
|
244
|
-
}
|
|
245
|
-
return operation();
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
268
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* All methods throw descriptive errors for invalid inputs.
|
|
11
11
|
*/
|
|
12
|
+
import { ValidationError } from './CategorisedError.js';
|
|
12
13
|
export class InputValidator {
|
|
13
14
|
// Valid scale names from MusicTheory service
|
|
14
15
|
static VALID_SCALES = [
|
|
@@ -108,11 +109,11 @@ export class InputValidator {
|
|
|
108
109
|
throw new Error('Steps must be a positive integer');
|
|
109
110
|
}
|
|
110
111
|
if (steps > 256) {
|
|
111
|
-
throw new
|
|
112
|
+
throw new ValidationError(`Steps cannot exceed 256, got ${steps}`);
|
|
112
113
|
}
|
|
113
114
|
// Validate relationship
|
|
114
115
|
if (hits > steps) {
|
|
115
|
-
throw new
|
|
116
|
+
throw new ValidationError(`Hits (${hits}) cannot exceed steps (${steps})`);
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
/**
|
|
@@ -182,7 +183,9 @@ export class InputValidator {
|
|
|
182
183
|
*
|
|
183
184
|
* @example
|
|
184
185
|
* InputValidator.validateRootNote('C'); // ✓ Valid
|
|
185
|
-
* InputValidator.validateRootNote('f#'); // ✓ Valid
|
|
186
|
+
* InputValidator.validateRootNote('f#'); // ✓ Valid — accepted, NOT
|
|
187
|
+
* // normalized; returns void
|
|
188
|
+
* // and the caller keeps 'f#' (#491)
|
|
186
189
|
* InputValidator.validateRootNote('X'); // ✗ Throws: Invalid root note
|
|
187
190
|
*/
|
|
188
191
|
static validateRootNote(note) {
|
|
@@ -201,14 +204,45 @@ export class InputValidator {
|
|
|
201
204
|
throw new Error(`Invalid root note: ${note}. Valid notes: ${this.VALID_ROOT_NOTES.join(', ')}`);
|
|
202
205
|
}
|
|
203
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Validates that a value is a plain JavaScript-style identifier.
|
|
209
|
+
*
|
|
210
|
+
* Use this wherever a caller-supplied name is interpolated into a
|
|
211
|
+
* RegExp or into generated pattern source. `effect({action:"remove"})`
|
|
212
|
+
* built `new RegExp(`\\.${args.effect}\\(...`)` from a raw argument,
|
|
213
|
+
* so `effect: "(a+)+Z"` against a crafted pattern blocked the event
|
|
214
|
+
* loop for 25 seconds — the server stops answering stdio entirely (#236).
|
|
215
|
+
*
|
|
216
|
+
* @param value - Value to validate
|
|
217
|
+
* @param fieldName - Field name for the error message
|
|
218
|
+
* @throws {Error} When the value is not a plain identifier
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* InputValidator.validateIdentifier('lpf', 'effect'); // ✓ Valid
|
|
222
|
+
* InputValidator.validateIdentifier('(a+)+Z', 'effect'); // ✗ Throws
|
|
223
|
+
*
|
|
224
|
+
* @nist si-10 "Information input validation"
|
|
225
|
+
*/
|
|
226
|
+
static validateIdentifier(value, fieldName) {
|
|
227
|
+
if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
228
|
+
throw new Error(`Invalid ${fieldName}: ${String(value)}. ` +
|
|
229
|
+
'Must be a plain identifier (letters, digits and underscore, not starting with a digit).');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
204
232
|
/**
|
|
205
233
|
* Validates string length to prevent resource exhaustion
|
|
206
234
|
*
|
|
207
235
|
* @param str - String to validate
|
|
208
236
|
* @param fieldName - Field name for error messages
|
|
209
|
-
* @param maxLength - Maximum allowed length
|
|
210
|
-
*
|
|
211
|
-
* @
|
|
237
|
+
* @param maxLength - Maximum allowed length, in UTF-16 code units
|
|
238
|
+
* (default: 1000). Must be a finite non-negative number.
|
|
239
|
+
* @param allowEmpty - Whether to allow empty strings. The default is
|
|
240
|
+
* `true`; callers that need an identifier pass `false` explicitly.
|
|
241
|
+
* This said "false for identifiers, true for content", describing a
|
|
242
|
+
* default that varies by field name and never existed — the default
|
|
243
|
+
* is one value and it is `true` (#491).
|
|
244
|
+
* @throws {Error} When string exceeds maximum length, is not a string,
|
|
245
|
+
* or `maxLength` is not a usable bound
|
|
212
246
|
*
|
|
213
247
|
* @example
|
|
214
248
|
* InputValidator.validateStringLength('hello', 'pattern', 100); // ✓ Valid
|
|
@@ -220,11 +254,25 @@ export class InputValidator {
|
|
|
220
254
|
if (typeof str !== 'string') {
|
|
221
255
|
throw new Error(`${fieldName} must be a string`);
|
|
222
256
|
}
|
|
257
|
+
// A bound that cannot bound anything is a caller bug, not a limit.
|
|
258
|
+
//
|
|
259
|
+
// `str.length > NaN` is false, as is every comparison with NaN, so
|
|
260
|
+
// a NaN maxLength silently disabled the check entirely: a
|
|
261
|
+
// million-character string was accepted by the function whose whole
|
|
262
|
+
// purpose is preventing resource exhaustion. `maxLength` itself was
|
|
263
|
+
// never validated (#491).
|
|
264
|
+
if (!Number.isFinite(maxLength) || maxLength < 0) {
|
|
265
|
+
throw new Error(`${fieldName} length check misconfigured: maxLength must be a finite ` +
|
|
266
|
+
`non-negative number, got ${String(maxLength)}`);
|
|
267
|
+
}
|
|
223
268
|
if (!allowEmpty && str.trim().length === 0) {
|
|
224
269
|
throw new Error(`${fieldName} cannot be empty`);
|
|
225
270
|
}
|
|
271
|
+
// UTF-16 code units, which is what `.length` counts and what a
|
|
272
|
+
// resource bound should count. The message used to say "characters",
|
|
273
|
+
// so a single emoji was reported as 2 of them (#491).
|
|
226
274
|
if (str.length > maxLength) {
|
|
227
|
-
throw new Error(`${fieldName} too long (max ${maxLength}
|
|
275
|
+
throw new Error(`${fieldName} too long (max ${maxLength} UTF-16 code units, got ${str.length})`);
|
|
228
276
|
}
|
|
229
277
|
}
|
|
230
278
|
/**
|