clearctx 3.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,618 @@
1
+ /**
2
+ * Pipeline Engine - Layer 3 Reactive Automation System
3
+ *
4
+ * This class manages reactive pipelines: "when X happens, automatically do Y."
5
+ * It listens for events (artifact published, contract completed, etc.) and
6
+ * fires rules that match the trigger conditions.
7
+ *
8
+ * Architecture:
9
+ * - Pipelines contain rules
10
+ * - Each rule has: trigger (when to fire), condition (filter), action (what to do)
11
+ * - Triggers match against events from the system
12
+ * - Actions can notify sessions, broadcast, or return actions for caller to execute
13
+ *
14
+ * @class PipelineEngine
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ // Import our atomic file operations and locking utilities
22
+ const { atomicWriteJson, readJsonSafe, appendJsonl } = require('./atomic-io');
23
+ const { acquireLock, releaseLock } = require('./file-lock');
24
+ const TeamHub = require('./team-hub');
25
+
26
+ /**
27
+ * PipelineEngine class - manages reactive automation pipelines
28
+ */
29
+ class PipelineEngine {
30
+ /**
31
+ * Creates a new PipelineEngine instance
32
+ *
33
+ * @param {string} teamName - Name of the team (default: 'default')
34
+ */
35
+ constructor(teamName = 'default') {
36
+ // Set the team name
37
+ this.teamName = teamName;
38
+
39
+ // Create the base directory path: ~/.clearctx
40
+ const baseDir = path.join(os.homedir(), '.clearctx');
41
+
42
+ // Create the pipelines directory path: ~/.clearctx/team/{teamName}/pipelines
43
+ this.pipelinesDir = path.join(baseDir, 'team', teamName, 'pipelines');
44
+
45
+ // Set paths for pipeline data
46
+ this.indexPath = path.join(this.pipelinesDir, 'index.json');
47
+ this.logPath = path.join(this.pipelinesDir, 'log.jsonl');
48
+ this.locksDir = path.join(baseDir, 'locks');
49
+
50
+ // Make sure all directories exist
51
+ fs.mkdirSync(this.pipelinesDir, { recursive: true });
52
+ fs.mkdirSync(this.locksDir, { recursive: true });
53
+
54
+ // Initialize TeamHub for messaging capabilities
55
+ this.teamHub = new TeamHub(teamName);
56
+
57
+ // Safety limit: max number of rule evaluations per event
58
+ // This prevents infinite loops or runaway pipeline execution
59
+ this.maxEvaluationsPerCycle = 50;
60
+ }
61
+
62
+ /**
63
+ * Create a new pipeline
64
+ *
65
+ * @param {string} pipelineId - Unique identifier for the pipeline
66
+ * @param {Object} options - Pipeline configuration
67
+ * @param {Array} options.rules - Array of rule objects
68
+ * @param {string} options.owner - Who created this pipeline
69
+ * @param {boolean} [options.enabled=true] - Whether pipeline is active
70
+ * @returns {Object} The created pipeline object
71
+ * @throws {Error} If pipelineId already exists or validation fails
72
+ */
73
+ create(pipelineId, { rules, owner, enabled = true }) {
74
+ // Acquire lock to prevent concurrent modifications
75
+ acquireLock(this.locksDir, 'pipelines-index');
76
+
77
+ try {
78
+ // Read current pipeline index
79
+ const index = readJsonSafe(this.indexPath, {});
80
+
81
+ // Check if pipeline already exists
82
+ if (index[pipelineId]) {
83
+ throw new Error(`Pipeline ${pipelineId} already exists`);
84
+ }
85
+
86
+ // Validate rules - each must have required fields
87
+ for (const rule of rules) {
88
+ // Every rule must have a unique ruleId
89
+ if (!rule.ruleId) {
90
+ throw new Error('Rule missing ruleId');
91
+ }
92
+
93
+ // Every rule must have a trigger with a type
94
+ if (!rule.trigger || !rule.trigger.type) {
95
+ throw new Error(`Rule ${rule.ruleId} missing trigger or trigger.type`);
96
+ }
97
+
98
+ // Every rule must have an action with a type
99
+ if (!rule.action || !rule.action.type) {
100
+ throw new Error(`Rule ${rule.ruleId} missing action or action.type`);
101
+ }
102
+ }
103
+
104
+ // Create the new pipeline object
105
+ const pipeline = {
106
+ pipelineId,
107
+ owner,
108
+ enabled,
109
+ createdAt: new Date().toISOString(),
110
+ rules,
111
+ executionCount: 0,
112
+ lastExecutedAt: null
113
+ };
114
+
115
+ // Add to index
116
+ index[pipelineId] = pipeline;
117
+
118
+ // Save the updated index
119
+ atomicWriteJson(this.indexPath, index);
120
+
121
+ return pipeline;
122
+ } finally {
123
+ // Always release the lock, even if an error occurred
124
+ releaseLock(this.locksDir, 'pipelines-index');
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Evaluate an event against all enabled pipelines
130
+ *
131
+ * @param {Object} event - The event to evaluate
132
+ * @param {string} event.type - Event type (e.g., "artifact_published")
133
+ * @param {string} [event.artifactId] - Artifact identifier (if applicable)
134
+ * @param {string} [event.artifactType] - Artifact type (if applicable)
135
+ * @param {string} [event.contractId] - Contract identifier (if applicable)
136
+ * @param {number} [event.version] - Version number (if applicable)
137
+ * @param {Object} [event.data] - Additional event data
138
+ * @returns {Object} Summary of evaluation results
139
+ */
140
+ evaluate(event) {
141
+ // Read pipeline index (no lock needed for reads - they're atomic)
142
+ const index = readJsonSafe(this.indexPath, {});
143
+
144
+ // Track evaluation statistics
145
+ let evaluationCount = 0;
146
+ const firedActions = [];
147
+
148
+ // Get all pipelines as an array
149
+ const pipelines = Object.values(index);
150
+
151
+ // Process each enabled pipeline
152
+ for (const pipeline of pipelines) {
153
+ // Skip disabled pipelines
154
+ if (!pipeline.enabled) {
155
+ continue;
156
+ }
157
+
158
+ // Evaluate each rule in the pipeline
159
+ for (const rule of pipeline.rules) {
160
+ // Check safety limit to prevent runaway execution
161
+ if (evaluationCount >= this.maxEvaluationsPerCycle) {
162
+ console.warn(
163
+ `Pipeline evaluation limit reached (${this.maxEvaluationsPerCycle}). ` +
164
+ `Stopping to prevent infinite loops.`
165
+ );
166
+ // Stop processing more rules
167
+ break;
168
+ }
169
+
170
+ // Increment evaluation counter
171
+ evaluationCount++;
172
+
173
+ // Check if trigger matches this event
174
+ const triggerMatches = this._matchTrigger(rule.trigger, event);
175
+ if (!triggerMatches) {
176
+ continue; // This rule doesn't apply to this event
177
+ }
178
+
179
+ // Check if condition passes (if there is one)
180
+ const conditionPasses = this._evaluateCondition(rule.condition, event);
181
+ if (!conditionPasses) {
182
+ continue; // Condition didn't pass, skip this rule
183
+ }
184
+
185
+ // Both trigger and condition passed - execute the action!
186
+ const actionResult = this._executeAction(rule.action, event);
187
+
188
+ // Log this execution to the append-only log
189
+ const logEntry = {
190
+ pipelineId: pipeline.pipelineId,
191
+ ruleId: rule.ruleId,
192
+ trigger: rule.trigger,
193
+ eventType: event.type,
194
+ eventSummary: {
195
+ artifactId: event.artifactId,
196
+ artifactType: event.artifactType,
197
+ contractId: event.contractId,
198
+ version: event.version
199
+ },
200
+ action: rule.action,
201
+ timestamp: new Date().toISOString()
202
+ };
203
+ appendJsonl(this.logPath, logEntry);
204
+
205
+ // Add to fired actions
206
+ firedActions.push({
207
+ pipelineId: pipeline.pipelineId,
208
+ ruleId: rule.ruleId,
209
+ result: actionResult
210
+ });
211
+
212
+ // Update pipeline execution stats
213
+ pipeline.executionCount++;
214
+ pipeline.lastExecutedAt = new Date().toISOString();
215
+ }
216
+ }
217
+
218
+ // Update pipeline stats in index (need lock for writes)
219
+ if (firedActions.length > 0) {
220
+ acquireLock(this.locksDir, 'pipelines-index');
221
+ try {
222
+ const currentIndex = readJsonSafe(this.indexPath, {});
223
+ // Update stats for pipelines that fired
224
+ for (const fired of firedActions) {
225
+ if (currentIndex[fired.pipelineId]) {
226
+ currentIndex[fired.pipelineId].executionCount =
227
+ (currentIndex[fired.pipelineId].executionCount || 0) + 1;
228
+ currentIndex[fired.pipelineId].lastExecutedAt = new Date().toISOString();
229
+ }
230
+ }
231
+ atomicWriteJson(this.indexPath, currentIndex);
232
+ } finally {
233
+ releaseLock(this.locksDir, 'pipelines-index');
234
+ }
235
+ }
236
+
237
+ // Return summary of what happened
238
+ return {
239
+ evaluated: evaluationCount,
240
+ fired: firedActions
241
+ };
242
+ }
243
+
244
+ /**
245
+ * List all pipelines, optionally filtered by owner
246
+ *
247
+ * @param {Object} [options={}] - Filter options
248
+ * @param {string} [options.owner] - Filter by owner name
249
+ * @returns {Array} Array of pipeline objects
250
+ */
251
+ list({ owner } = {}) {
252
+ // Read pipeline index
253
+ const index = readJsonSafe(this.indexPath, {});
254
+
255
+ // Get all pipelines as an array
256
+ let pipelines = Object.values(index);
257
+
258
+ // Filter by owner if specified
259
+ if (owner) {
260
+ pipelines = pipelines.filter(p => p.owner === owner);
261
+ }
262
+
263
+ return pipelines;
264
+ }
265
+
266
+ /**
267
+ * Pause (disable) a pipeline
268
+ *
269
+ * @param {string} pipelineId - Pipeline to pause
270
+ * @throws {Error} If pipeline not found
271
+ */
272
+ pause(pipelineId) {
273
+ // Acquire lock for modification
274
+ acquireLock(this.locksDir, 'pipelines-index');
275
+
276
+ try {
277
+ // Read current index
278
+ const index = readJsonSafe(this.indexPath, {});
279
+
280
+ // Check if pipeline exists
281
+ if (!index[pipelineId]) {
282
+ throw new Error(`Pipeline ${pipelineId} not found`);
283
+ }
284
+
285
+ // Set enabled to false
286
+ index[pipelineId].enabled = false;
287
+
288
+ // Save updated index
289
+ atomicWriteJson(this.indexPath, index);
290
+ } finally {
291
+ releaseLock(this.locksDir, 'pipelines-index');
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Resume (enable) a pipeline
297
+ *
298
+ * @param {string} pipelineId - Pipeline to resume
299
+ * @throws {Error} If pipeline not found
300
+ */
301
+ resume(pipelineId) {
302
+ // Acquire lock for modification
303
+ acquireLock(this.locksDir, 'pipelines-index');
304
+
305
+ try {
306
+ // Read current index
307
+ const index = readJsonSafe(this.indexPath, {});
308
+
309
+ // Check if pipeline exists
310
+ if (!index[pipelineId]) {
311
+ throw new Error(`Pipeline ${pipelineId} not found`);
312
+ }
313
+
314
+ // Set enabled to true
315
+ index[pipelineId].enabled = true;
316
+
317
+ // Save updated index
318
+ atomicWriteJson(this.indexPath, index);
319
+ } finally {
320
+ releaseLock(this.locksDir, 'pipelines-index');
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Get execution log entries
326
+ *
327
+ * @param {string|null} [pipelineId=null] - Filter by pipeline (null = all)
328
+ * @param {number} [limit=50] - Max number of entries to return
329
+ * @returns {Array} Array of log entries (most recent first)
330
+ */
331
+ getLog(pipelineId = null, limit = 50) {
332
+ // Check if log file exists
333
+ if (!fs.existsSync(this.logPath)) {
334
+ return []; // No log file yet
335
+ }
336
+
337
+ // Read the log file
338
+ const content = fs.readFileSync(this.logPath, 'utf-8');
339
+ const lines = content.trim().split('\n').filter(line => line.length > 0);
340
+
341
+ // Parse each line as JSON
342
+ let entries = lines.map(line => JSON.parse(line));
343
+
344
+ // Filter by pipelineId if specified
345
+ if (pipelineId) {
346
+ entries = entries.filter(e => e.pipelineId === pipelineId);
347
+ }
348
+
349
+ // Return last N entries in reverse order (most recent first)
350
+ return entries.slice(-limit).reverse();
351
+ }
352
+
353
+ // ========== HELPER METHODS ==========
354
+
355
+ /**
356
+ * Check if a trigger matches an event
357
+ *
358
+ * @private
359
+ * @param {Object} trigger - Rule trigger configuration
360
+ * @param {Object} event - Event to match against
361
+ * @returns {boolean} True if trigger matches event
362
+ */
363
+ _matchTrigger(trigger, event) {
364
+ // First check: trigger type must match event type
365
+ if (trigger.type !== event.type) {
366
+ return false;
367
+ }
368
+
369
+ // Check artifact type if specified in trigger
370
+ if (trigger.artifactType && event.artifactType) {
371
+ if (trigger.artifactType !== event.artifactType) {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ // Check artifact ID if specified in trigger (supports wildcards)
377
+ if (trigger.artifactId && event.artifactId) {
378
+ // Convert wildcard pattern to regex
379
+ // Example: "api-contract-*" becomes /^api-contract-.*$/
380
+ const pattern = trigger.artifactId.replace(/\*/g, '.*');
381
+ const regex = new RegExp(`^${pattern}$`);
382
+
383
+ if (!regex.test(event.artifactId)) {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ // Check contract ID if specified in trigger
389
+ if (trigger.contractId && event.contractId) {
390
+ if (trigger.contractId !== event.contractId) {
391
+ return false;
392
+ }
393
+ }
394
+
395
+ // All checks passed!
396
+ return true;
397
+ }
398
+
399
+ /**
400
+ * Evaluate a condition expression
401
+ *
402
+ * @private
403
+ * @param {string|null} condition - Condition expression or null
404
+ * @param {Object} event - Event data to evaluate against
405
+ * @returns {boolean} True if condition passes (or is null)
406
+ */
407
+ _evaluateCondition(condition, event) {
408
+ // If no condition, always pass
409
+ if (!condition || condition === null || condition === undefined) {
410
+ return true;
411
+ }
412
+
413
+ // Parse simple expressions like "data.failed > 0" or "version >= 3"
414
+ // Supported operators: >, <, >=, <=, ==, !=
415
+ const operators = ['>=', '<=', '==', '!=', '>', '<'];
416
+ let operator = null;
417
+ let parts = null;
418
+
419
+ // Find which operator is used
420
+ for (const op of operators) {
421
+ if (condition.includes(op)) {
422
+ operator = op;
423
+ parts = condition.split(op).map(s => s.trim());
424
+ break;
425
+ }
426
+ }
427
+
428
+ // If no operator found or invalid split, fail safely
429
+ if (!operator || !parts || parts.length !== 2) {
430
+ console.warn(`Invalid condition expression: ${condition}`);
431
+ return false;
432
+ }
433
+
434
+ const leftSide = parts[0];
435
+ const rightSide = parts[1];
436
+
437
+ // Evaluate left side (get value from event)
438
+ let leftValue;
439
+ if (leftSide.startsWith('data.')) {
440
+ // Extract from event.data
441
+ const fieldName = leftSide.substring(5); // Remove "data."
442
+ leftValue = event.data ? event.data[fieldName] : undefined;
443
+ } else if (leftSide === 'version') {
444
+ leftValue = event.version;
445
+ } else {
446
+ // Unknown left side
447
+ return false;
448
+ }
449
+
450
+ // If left value is undefined, condition fails
451
+ if (leftValue === undefined) {
452
+ return false;
453
+ }
454
+
455
+ // Parse right side as number
456
+ const rightValue = parseFloat(rightSide);
457
+ if (isNaN(rightValue)) {
458
+ console.warn(`Right side is not a number: ${rightSide}`);
459
+ return false;
460
+ }
461
+
462
+ // Convert left value to number
463
+ const leftNum = parseFloat(leftValue);
464
+ if (isNaN(leftNum)) {
465
+ console.warn(`Left side is not a number: ${leftValue}`);
466
+ return false;
467
+ }
468
+
469
+ // Evaluate based on operator
470
+ switch (operator) {
471
+ case '>':
472
+ return leftNum > rightValue;
473
+ case '<':
474
+ return leftNum < rightValue;
475
+ case '>=':
476
+ return leftNum >= rightValue;
477
+ case '<=':
478
+ return leftNum <= rightValue;
479
+ case '==':
480
+ return leftNum === rightValue;
481
+ case '!=':
482
+ return leftNum !== rightValue;
483
+ default:
484
+ return false;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Execute an action or return it for caller to handle
490
+ *
491
+ * @private
492
+ * @param {Object} action - Action configuration
493
+ * @param {Object} event - Event that triggered this action
494
+ * @returns {Object} Action result
495
+ */
496
+ _executeAction(action, event) {
497
+ // Get the action type
498
+ const actionType = action.type;
499
+
500
+ // Handle "notify_session" - send direct message via TeamHub
501
+ if (actionType === 'notify_session') {
502
+ // Interpolate message template with event data
503
+ const message = this._interpolate(action.message, event);
504
+
505
+ // Send direct message to target session
506
+ this.teamHub.sendDirect(
507
+ action.pipelineId || 'pipeline', // From: pipeline system
508
+ action.target, // To: target session
509
+ message
510
+ );
511
+
512
+ return {
513
+ executed: true,
514
+ type: 'notify_session'
515
+ };
516
+ }
517
+
518
+ // Handle "broadcast" - send message to all team members
519
+ if (actionType === 'broadcast') {
520
+ // Interpolate message template with event data
521
+ const message = this._interpolate(action.message, event);
522
+
523
+ // Broadcast to all team members
524
+ this.teamHub.sendBroadcast(
525
+ 'pipeline', // From: pipeline system
526
+ message
527
+ );
528
+
529
+ return {
530
+ executed: true,
531
+ type: 'broadcast'
532
+ };
533
+ }
534
+
535
+ // Handle "reopen_contract" - return for caller to execute
536
+ // We don't import ContractStore here to avoid circular dependencies
537
+ if (actionType === 'reopen_contract') {
538
+ return {
539
+ executed: false,
540
+ type: 'reopen_contract',
541
+ params: action
542
+ };
543
+ }
544
+
545
+ // Handle "create_contract" - return for caller to execute
546
+ if (actionType === 'create_contract') {
547
+ return {
548
+ executed: false,
549
+ type: 'create_contract',
550
+ params: action
551
+ };
552
+ }
553
+
554
+ // Handle "invalidate_downstream" - return for caller to execute
555
+ if (actionType === 'invalidate_downstream') {
556
+ return {
557
+ executed: false,
558
+ type: 'invalidate_downstream',
559
+ params: action
560
+ };
561
+ }
562
+
563
+ // Unknown action type
564
+ console.warn(`Unknown action type: ${actionType}`);
565
+ return {
566
+ executed: false,
567
+ type: 'unknown',
568
+ error: `Unknown action type: ${actionType}`
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Interpolate template variables in a message
574
+ *
575
+ * @private
576
+ * @param {string} template - Message template with ${variable} placeholders
577
+ * @param {Object} event - Event data to interpolate from
578
+ * @returns {string} Interpolated message
579
+ */
580
+ _interpolate(template, event) {
581
+ // Replace ${data.fieldName} with values from event.data
582
+ let result = template.replace(/\$\{data\.(\w+)\}/g, (match, fieldName) => {
583
+ if (event.data && event.data[fieldName] !== undefined) {
584
+ return event.data[fieldName];
585
+ }
586
+ return match; // Keep placeholder if no value found
587
+ });
588
+
589
+ // Replace ${version} with event.version
590
+ result = result.replace(/\$\{version\}/g, () => {
591
+ if (event.version !== undefined) {
592
+ return event.version;
593
+ }
594
+ return '${version}'; // Keep placeholder if no value
595
+ });
596
+
597
+ // Replace ${artifactId} with event.artifactId
598
+ result = result.replace(/\$\{artifactId\}/g, () => {
599
+ if (event.artifactId !== undefined) {
600
+ return event.artifactId;
601
+ }
602
+ return '${artifactId}'; // Keep placeholder if no value
603
+ });
604
+
605
+ // Replace ${contractId} with event.contractId
606
+ result = result.replace(/\$\{contractId\}/g, () => {
607
+ if (event.contractId !== undefined) {
608
+ return event.contractId;
609
+ }
610
+ return '${contractId}'; // Keep placeholder if no value
611
+ });
612
+
613
+ return result;
614
+ }
615
+ }
616
+
617
+ // Export the PipelineEngine class
618
+ module.exports = PipelineEngine;