@unrdf/knowledge-engine 5.0.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/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +64 -0
- package/src/browser-shims.mjs +343 -0
- package/src/browser.mjs +910 -0
- package/src/canonicalize.mjs +414 -0
- package/src/condition-cache.mjs +109 -0
- package/src/condition-evaluator.mjs +722 -0
- package/src/dark-matter-core.mjs +742 -0
- package/src/define-hook.mjs +213 -0
- package/src/effect-sandbox-browser.mjs +283 -0
- package/src/effect-sandbox-worker.mjs +170 -0
- package/src/effect-sandbox.mjs +517 -0
- package/src/engines/index.mjs +11 -0
- package/src/engines/rdf-engine.mjs +299 -0
- package/src/file-resolver.mjs +387 -0
- package/src/hook-executor-batching.mjs +277 -0
- package/src/hook-executor.mjs +870 -0
- package/src/hook-management.mjs +150 -0
- package/src/index.mjs +93 -0
- package/src/ken-parliment.mjs +119 -0
- package/src/ken.mjs +149 -0
- package/src/knowledge-engine/builtin-rules.mjs +190 -0
- package/src/knowledge-engine/inference-engine.mjs +418 -0
- package/src/knowledge-engine/knowledge-engine.mjs +317 -0
- package/src/knowledge-engine/pattern-dsl.mjs +142 -0
- package/src/knowledge-engine/pattern-matcher.mjs +215 -0
- package/src/knowledge-engine/rules.mjs +184 -0
- package/src/knowledge-engine.mjs +319 -0
- package/src/knowledge-hook-engine.mjs +360 -0
- package/src/knowledge-hook-manager.mjs +469 -0
- package/src/knowledge-substrate-core.mjs +927 -0
- package/src/lite.mjs +222 -0
- package/src/lockchain-writer-browser.mjs +414 -0
- package/src/lockchain-writer.mjs +602 -0
- package/src/monitoring/andon-signals.mjs +775 -0
- package/src/observability.mjs +531 -0
- package/src/parse.mjs +290 -0
- package/src/performance-optimizer.mjs +678 -0
- package/src/policy-pack.mjs +572 -0
- package/src/query-cache.mjs +116 -0
- package/src/query-optimizer.mjs +1051 -0
- package/src/query.mjs +306 -0
- package/src/reason.mjs +350 -0
- package/src/resolution-layer.mjs +506 -0
- package/src/schemas.mjs +1063 -0
- package/src/security/error-sanitizer.mjs +257 -0
- package/src/security/path-validator.mjs +194 -0
- package/src/security/sandbox-restrictions.mjs +331 -0
- package/src/security-validator.mjs +389 -0
- package/src/store-cache.mjs +137 -0
- package/src/telemetry.mjs +167 -0
- package/src/transaction.mjs +810 -0
- package/src/utils/adaptive-monitor.mjs +746 -0
- package/src/utils/circuit-breaker.mjs +513 -0
- package/src/utils/edge-case-handler.mjs +503 -0
- package/src/utils/memory-manager.mjs +498 -0
- package/src/utils/ring-buffer.mjs +282 -0
- package/src/validate.mjs +319 -0
- package/src/validators/index.mjs +338 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Production hook execution engine.
|
|
3
|
+
* @module hook-executor
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* Production-ready hook execution engine that evaluates conditions,
|
|
7
|
+
* executes hook lifecycles, and integrates with the knowledge engine.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createConditionEvaluator } from './condition-evaluator.mjs';
|
|
11
|
+
import { createEffectSandbox } from './effect-sandbox.mjs';
|
|
12
|
+
import { createErrorSanitizer } from './security/error-sanitizer.mjs';
|
|
13
|
+
import { createSandboxRestrictions } from './security/sandbox-restrictions.mjs';
|
|
14
|
+
import { createStore } from '@unrdf/oxigraph';
|
|
15
|
+
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
16
|
+
|
|
17
|
+
const tracer = trace.getTracer('unrdf');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Execute a knowledge hook with full lifecycle management.
|
|
21
|
+
* @param {Object} hook - The hook definition
|
|
22
|
+
* @param {Object} event - The hook event
|
|
23
|
+
* @param {Object} [options] - Execution options
|
|
24
|
+
* @returns {Promise<Object>} Hook execution result
|
|
25
|
+
*
|
|
26
|
+
* @throws {Error} If hook execution fails
|
|
27
|
+
*/
|
|
28
|
+
export async function executeHook(hook, event, options = {}) {
|
|
29
|
+
if (!hook || typeof hook !== 'object') {
|
|
30
|
+
throw new TypeError('executeHook: hook must be an object');
|
|
31
|
+
}
|
|
32
|
+
if (!event || typeof event !== 'object') {
|
|
33
|
+
throw new TypeError('executeHook: event must be an object');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
basePath = process.cwd(),
|
|
38
|
+
strictMode = false,
|
|
39
|
+
timeoutMs = 30000,
|
|
40
|
+
enableConditionEvaluation = true,
|
|
41
|
+
enableSandboxing = true,
|
|
42
|
+
sandboxConfig = {},
|
|
43
|
+
} = options;
|
|
44
|
+
|
|
45
|
+
const startTime = Date.now();
|
|
46
|
+
const executionId = `hook-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// Set up timeout
|
|
50
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
51
|
+
setTimeout(() => reject(new Error(`Hook execution timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const executionPromise = _executeHookLifecycle(hook, event, {
|
|
55
|
+
basePath,
|
|
56
|
+
strictMode,
|
|
57
|
+
enableConditionEvaluation,
|
|
58
|
+
enableSandboxing,
|
|
59
|
+
sandboxConfig,
|
|
60
|
+
executionId,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await Promise.race([executionPromise, timeoutPromise]);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...result,
|
|
67
|
+
executionId,
|
|
68
|
+
durationMs: Date.now() - startTime,
|
|
69
|
+
success: true,
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Sanitize error message to prevent information disclosure
|
|
73
|
+
const errorSanitizer = createErrorSanitizer();
|
|
74
|
+
const sanitizedError = errorSanitizer.sanitize(error);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
executionId,
|
|
78
|
+
durationMs: Date.now() - startTime,
|
|
79
|
+
success: false,
|
|
80
|
+
error: sanitizedError,
|
|
81
|
+
cancelled: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Execute the complete hook lifecycle.
|
|
88
|
+
* @param {Object} hook - The hook definition
|
|
89
|
+
* @param {Object} event - The hook event
|
|
90
|
+
* @param {Object} options - Execution options
|
|
91
|
+
* @returns {Promise<Object>} Lifecycle execution result
|
|
92
|
+
*/
|
|
93
|
+
async function _executeHookLifecycle(hook, event, options) {
|
|
94
|
+
const {
|
|
95
|
+
_basePath,
|
|
96
|
+
strictMode,
|
|
97
|
+
_enableConditionEvaluation,
|
|
98
|
+
enableSandboxing,
|
|
99
|
+
_sandboxConfig,
|
|
100
|
+
executionId,
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
return tracer.startActiveSpan('hook.evaluate', async span => {
|
|
104
|
+
try {
|
|
105
|
+
span.setAttributes({
|
|
106
|
+
'hook.execution_id': executionId,
|
|
107
|
+
'hook.has_before': !!hook.before,
|
|
108
|
+
'hook.has_when': !!hook.when,
|
|
109
|
+
'hook.has_run': !!hook.run,
|
|
110
|
+
'hook.has_after': !!hook.after,
|
|
111
|
+
'hook.strict_mode': strictMode,
|
|
112
|
+
'hook.sandboxing_enabled': enableSandboxing,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let currentEvent = { ...event };
|
|
116
|
+
let beforeResult = null;
|
|
117
|
+
let runResult = null;
|
|
118
|
+
let afterResult = null;
|
|
119
|
+
let conditionResult = null;
|
|
120
|
+
let cancelled = false;
|
|
121
|
+
let cancelReason = null;
|
|
122
|
+
|
|
123
|
+
const result = await _executeHookPhases(
|
|
124
|
+
hook,
|
|
125
|
+
currentEvent,
|
|
126
|
+
beforeResult,
|
|
127
|
+
runResult,
|
|
128
|
+
afterResult,
|
|
129
|
+
conditionResult,
|
|
130
|
+
cancelled,
|
|
131
|
+
cancelReason,
|
|
132
|
+
options
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
span.setAttributes({
|
|
136
|
+
'hook.cancelled': result.cancelled || false,
|
|
137
|
+
'hook.success': result.success || false,
|
|
138
|
+
'hook.phase': result.phase || 'unknown',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
span.recordException(error);
|
|
145
|
+
span.setStatus({
|
|
146
|
+
code: SpanStatusCode.ERROR,
|
|
147
|
+
message: error.message,
|
|
148
|
+
});
|
|
149
|
+
throw error;
|
|
150
|
+
} finally {
|
|
151
|
+
span.end();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function _executeHookPhases(
|
|
157
|
+
hook,
|
|
158
|
+
currentEvent,
|
|
159
|
+
beforeResult,
|
|
160
|
+
runResult,
|
|
161
|
+
afterResult,
|
|
162
|
+
conditionResult,
|
|
163
|
+
cancelled,
|
|
164
|
+
cancelReason,
|
|
165
|
+
options
|
|
166
|
+
) {
|
|
167
|
+
const {
|
|
168
|
+
basePath,
|
|
169
|
+
strictMode,
|
|
170
|
+
enableConditionEvaluation,
|
|
171
|
+
enableSandboxing,
|
|
172
|
+
sandboxConfig,
|
|
173
|
+
_executionId,
|
|
174
|
+
} = options;
|
|
175
|
+
|
|
176
|
+
// Phase 1: Before
|
|
177
|
+
if (hook.before) {
|
|
178
|
+
try {
|
|
179
|
+
if (enableSandboxing) {
|
|
180
|
+
const sandbox = createEffectSandbox(sandboxConfig);
|
|
181
|
+
beforeResult = await sandbox.executeEffect(hook.before, {
|
|
182
|
+
event: currentEvent,
|
|
183
|
+
store: currentEvent.context?.graph,
|
|
184
|
+
delta: currentEvent.payload?.delta,
|
|
185
|
+
metadata: currentEvent.context?.metadata || {},
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
beforeResult = await hook.before(currentEvent);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (beforeResult && beforeResult.cancel) {
|
|
192
|
+
cancelled = true;
|
|
193
|
+
cancelReason = beforeResult.reason || 'Cancelled in before phase';
|
|
194
|
+
return {
|
|
195
|
+
beforeResult,
|
|
196
|
+
cancelled: true,
|
|
197
|
+
cancelReason,
|
|
198
|
+
phase: 'before',
|
|
199
|
+
success: false,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Merge before result into event payload
|
|
204
|
+
if (beforeResult && typeof beforeResult === 'object' && !beforeResult.cancel) {
|
|
205
|
+
currentEvent = {
|
|
206
|
+
...currentEvent,
|
|
207
|
+
payload: { ...currentEvent.payload, ...beforeResult },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
// Sanitize error message
|
|
212
|
+
const errorSanitizer = createErrorSanitizer();
|
|
213
|
+
const sanitizedError = errorSanitizer.sanitize(error);
|
|
214
|
+
|
|
215
|
+
if (strictMode) {
|
|
216
|
+
throw new Error(`Before phase failed: ${sanitizedError}`);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
beforeResult: { error: sanitizedError },
|
|
220
|
+
cancelled: true,
|
|
221
|
+
cancelReason: `Before phase error: ${sanitizedError}`,
|
|
222
|
+
phase: 'before',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Phase 2: Condition Evaluation
|
|
228
|
+
if (enableConditionEvaluation && hook.when) {
|
|
229
|
+
try {
|
|
230
|
+
const evaluator = createConditionEvaluator({ basePath, strictMode });
|
|
231
|
+
conditionResult = await evaluator.evaluate(
|
|
232
|
+
hook.when,
|
|
233
|
+
currentEvent.context?.graph || createStore(),
|
|
234
|
+
currentEvent.context?.env || {}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Check if condition is satisfied
|
|
238
|
+
const isSatisfied = await evaluator.isSatisfied(
|
|
239
|
+
hook.when,
|
|
240
|
+
currentEvent.context?.graph || createStore(),
|
|
241
|
+
currentEvent.context?.env || {}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (!isSatisfied) {
|
|
245
|
+
return {
|
|
246
|
+
beforeResult,
|
|
247
|
+
conditionResult,
|
|
248
|
+
cancelled: true,
|
|
249
|
+
cancelReason: 'Condition not satisfied',
|
|
250
|
+
phase: 'condition',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// Sanitize error message
|
|
255
|
+
const errorSanitizer = createErrorSanitizer();
|
|
256
|
+
const sanitizedError = errorSanitizer.sanitize(error);
|
|
257
|
+
|
|
258
|
+
if (strictMode) {
|
|
259
|
+
throw new Error(`Condition evaluation failed: ${sanitizedError}`);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
beforeResult,
|
|
263
|
+
conditionResult: { error: sanitizedError },
|
|
264
|
+
cancelled: true,
|
|
265
|
+
cancelReason: `Condition evaluation error: ${sanitizedError}`,
|
|
266
|
+
phase: 'condition',
|
|
267
|
+
success: false,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Phase 3: Run
|
|
273
|
+
if (hook.run) {
|
|
274
|
+
try {
|
|
275
|
+
if (enableSandboxing) {
|
|
276
|
+
// Use security sandbox with restrictions
|
|
277
|
+
const sandboxRestrictions = createSandboxRestrictions(sandboxConfig);
|
|
278
|
+
runResult = await sandboxRestrictions.executeRestricted(hook.run, currentEvent);
|
|
279
|
+
} else {
|
|
280
|
+
runResult = await hook.run(currentEvent);
|
|
281
|
+
}
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// Sanitize error message
|
|
284
|
+
const errorSanitizer = createErrorSanitizer();
|
|
285
|
+
const sanitizedError = errorSanitizer.sanitize(error);
|
|
286
|
+
|
|
287
|
+
if (strictMode) {
|
|
288
|
+
throw new Error(`Run phase failed: ${sanitizedError}`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
beforeResult,
|
|
292
|
+
conditionResult,
|
|
293
|
+
runResult: { error: sanitizedError },
|
|
294
|
+
cancelled: true,
|
|
295
|
+
cancelReason: `Run phase error: ${sanitizedError}`,
|
|
296
|
+
phase: 'run',
|
|
297
|
+
success: false,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Phase 4: After
|
|
303
|
+
if (hook.after) {
|
|
304
|
+
try {
|
|
305
|
+
if (enableSandboxing) {
|
|
306
|
+
const sandbox = createEffectSandbox(sandboxConfig);
|
|
307
|
+
afterResult = await sandbox.executeEffect(hook.after, {
|
|
308
|
+
event: {
|
|
309
|
+
...currentEvent,
|
|
310
|
+
result: runResult,
|
|
311
|
+
cancelled: false,
|
|
312
|
+
},
|
|
313
|
+
store: currentEvent.context?.graph,
|
|
314
|
+
delta: currentEvent.payload?.delta,
|
|
315
|
+
metadata: currentEvent.context?.metadata || {},
|
|
316
|
+
});
|
|
317
|
+
} else {
|
|
318
|
+
afterResult = await hook.after({
|
|
319
|
+
...currentEvent,
|
|
320
|
+
result: runResult,
|
|
321
|
+
cancelled: false,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
// Sanitize error message
|
|
326
|
+
const errorSanitizer = createErrorSanitizer();
|
|
327
|
+
const sanitizedError = errorSanitizer.sanitize(error);
|
|
328
|
+
|
|
329
|
+
if (strictMode) {
|
|
330
|
+
throw new Error(`After phase failed: ${sanitizedError}`);
|
|
331
|
+
}
|
|
332
|
+
// After phase errors don't cancel the hook, just log them
|
|
333
|
+
afterResult = { error: sanitizedError };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const finalResult = {
|
|
338
|
+
beforeResult,
|
|
339
|
+
conditionResult,
|
|
340
|
+
runResult,
|
|
341
|
+
afterResult,
|
|
342
|
+
cancelled: false,
|
|
343
|
+
phase: 'completed',
|
|
344
|
+
success: !cancelled && (!runResult || !runResult.error),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Record hook result span
|
|
348
|
+
return tracer.startActiveSpan('hook.result', resultSpan => {
|
|
349
|
+
try {
|
|
350
|
+
resultSpan.setAttributes({
|
|
351
|
+
'hook.result.success': finalResult.success,
|
|
352
|
+
'hook.result.cancelled': finalResult.cancelled,
|
|
353
|
+
'hook.result.phase': finalResult.phase,
|
|
354
|
+
'hook.result.has_before': !!beforeResult,
|
|
355
|
+
'hook.result.has_condition': !!conditionResult,
|
|
356
|
+
'hook.result.has_run': !!runResult,
|
|
357
|
+
'hook.result.has_after': !!afterResult,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
resultSpan.setStatus({ code: SpanStatusCode.OK });
|
|
361
|
+
resultSpan.end();
|
|
362
|
+
return finalResult;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
resultSpan.recordException(error);
|
|
365
|
+
resultSpan.setStatus({
|
|
366
|
+
code: SpanStatusCode.ERROR,
|
|
367
|
+
message: error.message,
|
|
368
|
+
});
|
|
369
|
+
resultSpan.end();
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Circuit breaker states
|
|
377
|
+
* @enum {string}
|
|
378
|
+
*/
|
|
379
|
+
const CircuitBreakerState = {
|
|
380
|
+
CLOSED: 'closed',
|
|
381
|
+
OPEN: 'open',
|
|
382
|
+
HALF_OPEN: 'half-open',
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Create a hook executor with advanced features.
|
|
387
|
+
* @param {Object} [options] - Executor options
|
|
388
|
+
* @param {string} [options.basePath] - Base path for file resolution
|
|
389
|
+
* @param {boolean} [options.strictMode] - Enable strict error handling
|
|
390
|
+
* @param {number} [options.timeoutMs] - Default timeout in milliseconds
|
|
391
|
+
* @param {number} [options.totalBudgetMs] - Total timeout budget for batch execution (prevents N hooks * 30s cascade)
|
|
392
|
+
* @param {boolean} [options.enableConditionEvaluation] - Enable condition evaluation
|
|
393
|
+
* @param {boolean} [options.enableMetrics] - Enable execution metrics
|
|
394
|
+
* @param {number} [options.circuitBreakerThreshold] - Number of failures before circuit opens
|
|
395
|
+
* @param {number} [options.circuitBreakerResetMs] - Time before circuit half-opens
|
|
396
|
+
* @returns {Object} Hook executor instance
|
|
397
|
+
*/
|
|
398
|
+
export function createHookExecutor(options = {}) {
|
|
399
|
+
const {
|
|
400
|
+
basePath = process.cwd(),
|
|
401
|
+
strictMode = false,
|
|
402
|
+
timeoutMs = 30000,
|
|
403
|
+
totalBudgetMs = 60000, // Total budget for batch execution (prevents cascade)
|
|
404
|
+
enableConditionEvaluation = true,
|
|
405
|
+
enableMetrics = true,
|
|
406
|
+
enableSandboxing = true,
|
|
407
|
+
sandboxConfig = {},
|
|
408
|
+
missingDependencyPolicy = 'warn', // 'error' | 'warn' | 'ignore'
|
|
409
|
+
circuitBreakerThreshold = 5, // Open circuit after 5 consecutive failures
|
|
410
|
+
circuitBreakerResetMs = 30000, // Try again after 30s
|
|
411
|
+
} = options;
|
|
412
|
+
|
|
413
|
+
const metrics = {
|
|
414
|
+
totalExecutions: 0,
|
|
415
|
+
successfulExecutions: 0,
|
|
416
|
+
failedExecutions: 0,
|
|
417
|
+
cancelledExecutions: 0,
|
|
418
|
+
totalDuration: 0,
|
|
419
|
+
averageDuration: 0,
|
|
420
|
+
executionsByPhase: {
|
|
421
|
+
before: 0,
|
|
422
|
+
condition: 0,
|
|
423
|
+
run: 0,
|
|
424
|
+
after: 0,
|
|
425
|
+
completed: 0,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Circuit breaker state for timeout cascade prevention
|
|
430
|
+
const circuitBreaker = {
|
|
431
|
+
state: CircuitBreakerState.CLOSED,
|
|
432
|
+
failureCount: 0,
|
|
433
|
+
lastFailureTime: 0,
|
|
434
|
+
successCount: 0,
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
/**
|
|
439
|
+
* Execute a hook.
|
|
440
|
+
* @param {Object} hook - The hook definition
|
|
441
|
+
* @param {Object} event - The hook event
|
|
442
|
+
* @param {Object} [executionOptions] - Execution-specific options
|
|
443
|
+
* @returns {Promise<Object>} Execution result
|
|
444
|
+
*/
|
|
445
|
+
async execute(hook, event, executionOptions = {}) {
|
|
446
|
+
const mergedOptions = {
|
|
447
|
+
basePath,
|
|
448
|
+
strictMode,
|
|
449
|
+
timeoutMs,
|
|
450
|
+
enableConditionEvaluation,
|
|
451
|
+
enableSandboxing,
|
|
452
|
+
sandboxConfig,
|
|
453
|
+
...executionOptions,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const result = await executeHook(hook, event, mergedOptions);
|
|
457
|
+
|
|
458
|
+
if (enableMetrics) {
|
|
459
|
+
this._updateMetrics(result);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return result;
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Execute multiple hooks in parallel with budget tracking.
|
|
467
|
+
* Prevents timeout cascade where N hooks * 30s = N*30s total wait.
|
|
468
|
+
* @param {Array} hooks - Array of hook definitions
|
|
469
|
+
* @param {Object} event - The hook event
|
|
470
|
+
* @param {Object} [executionOptions] - Execution-specific options
|
|
471
|
+
* @returns {Promise<Object>} Results with succeeded/failed arrays
|
|
472
|
+
*/
|
|
473
|
+
async executeAll(hooks, event, executionOptions = {}) {
|
|
474
|
+
if (!Array.isArray(hooks)) {
|
|
475
|
+
throw new TypeError('executeAll: hooks must be an array');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Check circuit breaker before execution
|
|
479
|
+
if (this._isCircuitOpen()) {
|
|
480
|
+
return {
|
|
481
|
+
succeeded: [],
|
|
482
|
+
failed: hooks.map((hook, idx) => ({
|
|
483
|
+
hookIndex: idx,
|
|
484
|
+
hookName: hook?.meta?.name || `hook-${idx}`,
|
|
485
|
+
error: 'Circuit breaker open - too many recent failures',
|
|
486
|
+
circuitBreakerTripped: true,
|
|
487
|
+
})),
|
|
488
|
+
circuitBreakerOpen: true,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const budgetMs = executionOptions.totalBudgetMs || totalBudgetMs;
|
|
493
|
+
const perHookTimeout = Math.min(
|
|
494
|
+
executionOptions.timeoutMs || timeoutMs,
|
|
495
|
+
Math.floor(budgetMs / Math.max(hooks.length, 1))
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
const batchStartTime = Date.now();
|
|
499
|
+
const succeeded = [];
|
|
500
|
+
const failed = [];
|
|
501
|
+
|
|
502
|
+
// Use Promise.allSettled to handle partial failures
|
|
503
|
+
const promises = hooks.map((hook, idx) => {
|
|
504
|
+
// Calculate remaining budget for this hook
|
|
505
|
+
const elapsed = Date.now() - batchStartTime;
|
|
506
|
+
const remainingBudget = budgetMs - elapsed;
|
|
507
|
+
|
|
508
|
+
if (remainingBudget <= 0) {
|
|
509
|
+
return Promise.resolve({
|
|
510
|
+
status: 'rejected',
|
|
511
|
+
reason: new Error('Budget exhausted - hook skipped to prevent cascade'),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const hookTimeout = Math.min(perHookTimeout, remainingBudget);
|
|
516
|
+
|
|
517
|
+
return this.execute(hook, event, {
|
|
518
|
+
...executionOptions,
|
|
519
|
+
timeoutMs: hookTimeout,
|
|
520
|
+
})
|
|
521
|
+
.then(result => ({
|
|
522
|
+
status: 'fulfilled',
|
|
523
|
+
value: result,
|
|
524
|
+
hookIndex: idx,
|
|
525
|
+
}))
|
|
526
|
+
.catch(error => ({
|
|
527
|
+
status: 'rejected',
|
|
528
|
+
reason: error,
|
|
529
|
+
hookIndex: idx,
|
|
530
|
+
}));
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const results = await Promise.allSettled(promises);
|
|
534
|
+
|
|
535
|
+
for (let i = 0; i < results.length; i++) {
|
|
536
|
+
const result = results[i];
|
|
537
|
+
const hook = hooks[i];
|
|
538
|
+
const hookName = hook?.meta?.name || `hook-${i}`;
|
|
539
|
+
|
|
540
|
+
if (result.status === 'fulfilled' && result.value?.status === 'fulfilled') {
|
|
541
|
+
succeeded.push({
|
|
542
|
+
hookIndex: i,
|
|
543
|
+
hookName,
|
|
544
|
+
result: result.value.value,
|
|
545
|
+
});
|
|
546
|
+
this._recordCircuitSuccess();
|
|
547
|
+
} else {
|
|
548
|
+
const error =
|
|
549
|
+
result.status === 'rejected' ? result.reason : result.value?.reason || 'Unknown error';
|
|
550
|
+
failed.push({
|
|
551
|
+
hookIndex: i,
|
|
552
|
+
hookName,
|
|
553
|
+
error: error?.message || String(error),
|
|
554
|
+
});
|
|
555
|
+
this._recordCircuitFailure();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
succeeded,
|
|
561
|
+
failed,
|
|
562
|
+
totalBudgetMs: budgetMs,
|
|
563
|
+
actualDurationMs: Date.now() - batchStartTime,
|
|
564
|
+
circuitBreakerState: circuitBreaker.state,
|
|
565
|
+
};
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Check if circuit breaker is open
|
|
570
|
+
* @returns {boolean}
|
|
571
|
+
* @private
|
|
572
|
+
*/
|
|
573
|
+
_isCircuitOpen() {
|
|
574
|
+
if (circuitBreaker.state === CircuitBreakerState.CLOSED) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (circuitBreaker.state === CircuitBreakerState.OPEN) {
|
|
579
|
+
// Check if enough time has passed to try again
|
|
580
|
+
const timeSinceFailure = Date.now() - circuitBreaker.lastFailureTime;
|
|
581
|
+
if (timeSinceFailure >= circuitBreakerResetMs) {
|
|
582
|
+
circuitBreaker.state = CircuitBreakerState.HALF_OPEN;
|
|
583
|
+
circuitBreaker.successCount = 0;
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// HALF_OPEN - allow through to test
|
|
590
|
+
return false;
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Record a circuit breaker success
|
|
595
|
+
* @private
|
|
596
|
+
*/
|
|
597
|
+
_recordCircuitSuccess() {
|
|
598
|
+
if (circuitBreaker.state === CircuitBreakerState.HALF_OPEN) {
|
|
599
|
+
circuitBreaker.successCount++;
|
|
600
|
+
// After 3 successes in half-open, close the circuit
|
|
601
|
+
if (circuitBreaker.successCount >= 3) {
|
|
602
|
+
circuitBreaker.state = CircuitBreakerState.CLOSED;
|
|
603
|
+
circuitBreaker.failureCount = 0;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
circuitBreaker.failureCount = 0;
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Record a circuit breaker failure
|
|
612
|
+
* @private
|
|
613
|
+
*/
|
|
614
|
+
_recordCircuitFailure() {
|
|
615
|
+
circuitBreaker.failureCount++;
|
|
616
|
+
circuitBreaker.lastFailureTime = Date.now();
|
|
617
|
+
|
|
618
|
+
if (circuitBreaker.failureCount >= circuitBreakerThreshold) {
|
|
619
|
+
circuitBreaker.state = CircuitBreakerState.OPEN;
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Get circuit breaker status
|
|
625
|
+
* @returns {Object}
|
|
626
|
+
*/
|
|
627
|
+
getCircuitBreakerStatus() {
|
|
628
|
+
return {
|
|
629
|
+
state: circuitBreaker.state,
|
|
630
|
+
failureCount: circuitBreaker.failureCount,
|
|
631
|
+
lastFailureTime: circuitBreaker.lastFailureTime,
|
|
632
|
+
threshold: circuitBreakerThreshold,
|
|
633
|
+
resetMs: circuitBreakerResetMs,
|
|
634
|
+
};
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Reset circuit breaker
|
|
639
|
+
*/
|
|
640
|
+
resetCircuitBreaker() {
|
|
641
|
+
circuitBreaker.state = CircuitBreakerState.CLOSED;
|
|
642
|
+
circuitBreaker.failureCount = 0;
|
|
643
|
+
circuitBreaker.lastFailureTime = 0;
|
|
644
|
+
circuitBreaker.successCount = 0;
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Execute hooks sequentially.
|
|
649
|
+
* @param {Array} hooks - Array of hook definitions
|
|
650
|
+
* @param {Object} event - The hook event
|
|
651
|
+
* @param {Object} [executionOptions] - Execution-specific options
|
|
652
|
+
* @returns {Promise<Array>} Array of execution results
|
|
653
|
+
*/
|
|
654
|
+
async executeSequential(hooks, event, executionOptions = {}) {
|
|
655
|
+
if (!Array.isArray(hooks)) {
|
|
656
|
+
throw new TypeError('executeSequential: hooks must be an array');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const results = [];
|
|
660
|
+
for (const hook of hooks) {
|
|
661
|
+
const result = await this.execute(hook, event, executionOptions);
|
|
662
|
+
results.push(result);
|
|
663
|
+
|
|
664
|
+
// Stop on first failure in strict mode
|
|
665
|
+
if (executionOptions.strictMode && !result.success) {
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return results;
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Execute hooks with dependency resolution.
|
|
675
|
+
* @param {Array} hooks - Array of hook definitions with dependencies
|
|
676
|
+
* @param {Object} event - The hook event
|
|
677
|
+
* @param {Object} [executionOptions] - Execution-specific options
|
|
678
|
+
* @returns {Promise<Array>} Array of execution results
|
|
679
|
+
*/
|
|
680
|
+
async executeWithDependencies(hooks, event, executionOptions = {}) {
|
|
681
|
+
if (!Array.isArray(hooks)) {
|
|
682
|
+
throw new TypeError('executeWithDependencies: hooks must be an array');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Build dependency graph from hook.meta.dependencies
|
|
686
|
+
const nameOf = (hook, idx) => hook?.meta?.name || `hook-${idx}`;
|
|
687
|
+
const graph = new Map(); // name -> Set(dependencies)
|
|
688
|
+
const byName = new Map(); // name -> hook
|
|
689
|
+
|
|
690
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
691
|
+
const hook = hooks[i];
|
|
692
|
+
const name = nameOf(hook, i);
|
|
693
|
+
byName.set(name, hook);
|
|
694
|
+
const deps = Array.isArray(hook?.meta?.dependencies) ? hook.meta.dependencies : [];
|
|
695
|
+
graph.set(name, new Set(deps));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Kahn's algorithm for topological sort with cycle tolerance
|
|
699
|
+
const inDegree = new Map();
|
|
700
|
+
// Initialize in-degrees
|
|
701
|
+
for (const [name, deps] of graph.entries()) {
|
|
702
|
+
if (!inDegree.has(name)) inDegree.set(name, 0);
|
|
703
|
+
for (const dep of deps) {
|
|
704
|
+
if (!graph.has(dep)) {
|
|
705
|
+
const policy = executionOptions.missingDependencyPolicy || missingDependencyPolicy;
|
|
706
|
+
if (policy === 'error') {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`executeWithDependencies: missing dependency '${dep}' for hook '${name}'`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (policy === 'warn') {
|
|
712
|
+
console.warn(
|
|
713
|
+
`executeWithDependencies: missing dependency '${dep}' for hook '${name}' (continuing)`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
continue; // ignore
|
|
717
|
+
}
|
|
718
|
+
inDegree.set(name, (inDegree.get(name) || 0) + 1);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const queue = [];
|
|
723
|
+
for (const [name, deg] of inDegree.entries()) {
|
|
724
|
+
if (deg === 0) queue.push(name);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const orderedNames = [];
|
|
728
|
+
while (queue.length > 0) {
|
|
729
|
+
const current = queue.shift();
|
|
730
|
+
orderedNames.push(current);
|
|
731
|
+
// Reduce in-degree of nodes that depend on current
|
|
732
|
+
for (const [name, deps] of graph.entries()) {
|
|
733
|
+
if (deps.has(current)) {
|
|
734
|
+
const newDeg = (inDegree.get(name) || 0) - 1;
|
|
735
|
+
inDegree.set(name, newDeg);
|
|
736
|
+
if (newDeg === 0) queue.push(name);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Detect cycles or unresolved nodes
|
|
742
|
+
if (orderedNames.length < hooks.length) {
|
|
743
|
+
const unresolved = hooks.map((h, i) => nameOf(h, i)).filter(n => !orderedNames.includes(n));
|
|
744
|
+
const policy = executionOptions.missingDependencyPolicy || missingDependencyPolicy;
|
|
745
|
+
if (policy === 'error') {
|
|
746
|
+
throw new Error(
|
|
747
|
+
`executeWithDependencies: cyclic or unresolved dependencies among: ${unresolved.join(', ')}`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
// Append unresolved in original order as a last resort
|
|
751
|
+
orderedNames.push(...unresolved);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const orderedHooks = orderedNames.map(n => byName.get(n));
|
|
755
|
+
return this.executeSequential(orderedHooks, event, executionOptions);
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Get execution metrics.
|
|
760
|
+
* @returns {Object} Current metrics
|
|
761
|
+
*/
|
|
762
|
+
getMetrics() {
|
|
763
|
+
if (!enableMetrics) {
|
|
764
|
+
return { metricsDisabled: true };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
...metrics,
|
|
769
|
+
successRate:
|
|
770
|
+
metrics.totalExecutions > 0 ? metrics.successfulExecutions / metrics.totalExecutions : 0,
|
|
771
|
+
failureRate:
|
|
772
|
+
metrics.totalExecutions > 0 ? metrics.failedExecutions / metrics.totalExecutions : 0,
|
|
773
|
+
cancellationRate:
|
|
774
|
+
metrics.totalExecutions > 0 ? metrics.cancelledExecutions / metrics.totalExecutions : 0,
|
|
775
|
+
};
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Reset metrics.
|
|
780
|
+
*/
|
|
781
|
+
resetMetrics() {
|
|
782
|
+
if (enableMetrics) {
|
|
783
|
+
Object.assign(metrics, {
|
|
784
|
+
totalExecutions: 0,
|
|
785
|
+
successfulExecutions: 0,
|
|
786
|
+
failedExecutions: 0,
|
|
787
|
+
cancelledExecutions: 0,
|
|
788
|
+
totalDuration: 0,
|
|
789
|
+
averageDuration: 0,
|
|
790
|
+
executionsByPhase: {
|
|
791
|
+
before: 0,
|
|
792
|
+
condition: 0,
|
|
793
|
+
run: 0,
|
|
794
|
+
after: 0,
|
|
795
|
+
completed: 0,
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Update metrics with execution result.
|
|
803
|
+
* @param {Object} result - Execution result
|
|
804
|
+
* @private
|
|
805
|
+
*/
|
|
806
|
+
_updateMetrics(result) {
|
|
807
|
+
metrics.totalExecutions++;
|
|
808
|
+
metrics.totalDuration += result.durationMs || 0;
|
|
809
|
+
metrics.averageDuration = metrics.totalDuration / metrics.totalExecutions;
|
|
810
|
+
|
|
811
|
+
if (result.success) {
|
|
812
|
+
metrics.successfulExecutions++;
|
|
813
|
+
} else {
|
|
814
|
+
metrics.failedExecutions++;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (result.cancelled) {
|
|
818
|
+
metrics.cancelledExecutions++;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (result.phase && metrics.executionsByPhase[result.phase] !== undefined) {
|
|
822
|
+
metrics.executionsByPhase[result.phase]++;
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Validate a hook definition for execution.
|
|
830
|
+
* @param {Object} hook - The hook definition
|
|
831
|
+
* @returns {Object} Validation result
|
|
832
|
+
*/
|
|
833
|
+
export function validateHookForExecution(hook) {
|
|
834
|
+
if (!hook || typeof hook !== 'object') {
|
|
835
|
+
return { valid: false, error: 'Hook must be an object' };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (!hook.meta || !hook.meta.name) {
|
|
839
|
+
return { valid: false, error: 'Hook must have meta.name' };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!hook.run || typeof hook.run !== 'function') {
|
|
843
|
+
return { valid: false, error: 'Hook must have a run function' };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (!hook.when) {
|
|
847
|
+
return { valid: false, error: 'Hook must have a when condition' };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (!hook.when.kind) {
|
|
851
|
+
return { valid: false, error: 'Hook when condition must have a kind' };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (!hook.when.ref) {
|
|
855
|
+
return { valid: false, error: 'Hook when condition must have a ref' };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!hook.when.ref.uri) {
|
|
859
|
+
return { valid: false, error: 'Hook when condition ref must have a uri' };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (!hook.when.ref.sha256) {
|
|
863
|
+
return {
|
|
864
|
+
valid: false,
|
|
865
|
+
error: 'Hook when condition ref must have a sha256 hash',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return { valid: true };
|
|
870
|
+
}
|