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,453 @@
1
+ /**
2
+ * dependency-resolver.js
3
+ * Layer 2: Dependency Resolution Engine for clearctx
4
+ *
5
+ * This is the CORE algorithm that automatically advances contracts when their
6
+ * dependencies are satisfied. It runs after every artifact publish and contract
7
+ * status change to check if new work can be started or completed.
8
+ *
9
+ * Think of it like a project manager who:
10
+ * 1. Checks if blocked tasks can now start (dependencies satisfied)
11
+ * 2. Checks if running tasks are taking too long (timeouts)
12
+ * 3. Checks if completed work meets acceptance criteria (auto-completion)
13
+ * 4. Notifies team members when their tasks are ready
14
+ *
15
+ * @class DependencyResolver
16
+ */
17
+
18
+ const ArtifactStore = require('./artifact-store');
19
+ const ContractStore = require('./contract-store');
20
+ const TeamHub = require('./team-hub');
21
+ const { acquireLock, releaseLock } = require('./file-lock');
22
+ const path = require('path');
23
+ const os = require('os');
24
+
25
+ /**
26
+ * DependencyResolver - Automatically advances contracts when dependencies are met
27
+ *
28
+ * This class is called after:
29
+ * - An artifact is published (might satisfy artifact dependencies)
30
+ * - A contract changes status (might satisfy contract dependencies)
31
+ *
32
+ * It performs a cascade of checks and transitions:
33
+ * - Pending contracts → Ready (when dependencies satisfied)
34
+ * - In-progress contracts → Failed (when timeout exceeded)
35
+ * - In-progress contracts → Completed (when acceptance criteria met)
36
+ */
37
+ class DependencyResolver {
38
+ /**
39
+ * Create a new DependencyResolver
40
+ *
41
+ * @param {string} teamName - The team name (default: 'default')
42
+ */
43
+ constructor(teamName = 'default') {
44
+ // Store the team name for later use
45
+ this.teamName = teamName;
46
+
47
+ // Create instances of the three core stores
48
+ // These let us read artifacts, contracts, and send messages
49
+ this.artifacts = new ArtifactStore(teamName);
50
+ this.contracts = new ContractStore(teamName);
51
+ this.teamHub = new TeamHub(teamName);
52
+
53
+ // Maximum cascade depth - prevents infinite loops
54
+ // If resolve() calls itself too many times, we stop
55
+ this.maxCascadeDepth = Number(process.env.CMS_MAX_CASCADE_DEPTH) || 10;
56
+
57
+ // Pipeline engine reference (set later to avoid circular require)
58
+ this.pipelineEngine = null;
59
+
60
+ // Set up the locks directory path
61
+ const baseDir = path.join(os.homedir(), '.clearctx');
62
+ this.locksDir = path.join(baseDir, 'team', teamName, 'locks');
63
+ }
64
+
65
+ /**
66
+ * Set the pipeline engine reference
67
+ * This is called after the pipeline engine is created to avoid circular dependencies
68
+ *
69
+ * @param {Object} engine - The pipeline engine instance
70
+ */
71
+ setPipelineEngine(engine) {
72
+ this.pipelineEngine = engine;
73
+ }
74
+
75
+ /**
76
+ * Main resolution method - checks all contracts and advances them if possible
77
+ *
78
+ * This is the heart of the dependency resolver. It:
79
+ * 1. Checks for timeouts
80
+ * 2. Advances pending contracts to ready
81
+ * 3. Auto-completes contracts that meet criteria
82
+ * 4. Cascades to handle newly completed contracts
83
+ *
84
+ * @param {Object} trigger - What triggered this resolution (for debugging)
85
+ * @param {number} depth - Current cascade depth (prevents infinite loops)
86
+ * @returns {Promise<Object>} Object with transitions array and depth
87
+ */
88
+ async resolve(trigger = {}, depth = 0) {
89
+ // Step 1: Check if we've cascaded too deep (safety check)
90
+ if (depth >= this.maxCascadeDepth) {
91
+ // Send a warning message to the orchestrator
92
+ const orchestratorInbox = this.teamHub.getInbox('orchestrator');
93
+ if (orchestratorInbox) {
94
+ this.teamHub.sendDirect(
95
+ 'dependency-resolver',
96
+ 'orchestrator',
97
+ `Cascade depth limit reached (${this.maxCascadeDepth}). Some contracts may need manual intervention.`,
98
+ { priority: 'urgent' }
99
+ );
100
+ }
101
+
102
+ return { cascadeLimited: true, depth };
103
+ }
104
+
105
+ // Step 2: Track all transitions that happen in this cycle
106
+ // A transition is when a contract moves from one status to another
107
+ const transitions = [];
108
+
109
+ // Step 3: Check for timed-out contracts
110
+ // Get all contracts that are currently in progress
111
+ const inProgressContracts = this.contracts.list({ status: 'in_progress' });
112
+
113
+ for (const contract of inProgressContracts) {
114
+ // Only check timeout if timeoutMs is set
115
+ if (contract.timeoutMs && contract.startedAt) {
116
+ // Calculate how long the contract has been running
117
+ const now = Date.now();
118
+ const startedTime = new Date(contract.startedAt).getTime();
119
+ const elapsedTime = now - startedTime;
120
+
121
+ // If elapsed time exceeds timeout, fail the contract
122
+ if (elapsedTime > contract.timeoutMs) {
123
+ try {
124
+ this.contracts.fail(contract.contractId, 'Timeout exceeded');
125
+
126
+ // Record this transition
127
+ transitions.push({
128
+ contractId: contract.contractId,
129
+ from: 'in_progress',
130
+ to: 'failed',
131
+ reason: 'timeout'
132
+ });
133
+
134
+ // Notify the assignee that their contract timed out
135
+ this.teamHub.sendDirect(
136
+ 'dependency-resolver',
137
+ contract.assignee,
138
+ `Contract "${contract.contractId}" timed out after ${contract.timeoutMs}ms`,
139
+ { priority: 'urgent' }
140
+ );
141
+ } catch (err) {
142
+ // If failing the contract throws an error, log it but continue
143
+ // (contract might have been modified by another process)
144
+ console.error(`Failed to timeout contract ${contract.contractId}:`, err.message);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // Step 4: Check pending contracts for satisfied dependencies
151
+ // Get all contracts that are waiting for dependencies
152
+ const pendingContracts = this.contracts.list({ status: 'pending' });
153
+
154
+ for (const contract of pendingContracts) {
155
+ // Check if ALL dependencies are satisfied
156
+ let allSatisfied = true;
157
+
158
+ // Loop through each dependency
159
+ for (const dep of contract.dependencies) {
160
+ const satisfied = this._checkDependency(dep);
161
+ if (!satisfied) {
162
+ allSatisfied = false;
163
+ break; // No need to check more if one is not satisfied
164
+ }
165
+ }
166
+
167
+ // If all dependencies are satisfied, transition to ready
168
+ if (allSatisfied) {
169
+ try {
170
+ // Use the helper method to transition pending → ready
171
+ this._transitionToReady(contract.contractId);
172
+
173
+ // Record this transition
174
+ transitions.push({
175
+ contractId: contract.contractId,
176
+ from: 'pending',
177
+ to: 'ready',
178
+ reason: 'dependencies_satisfied'
179
+ });
180
+
181
+ // Notify the assignee that their contract is ready to start
182
+ this.teamHub.sendDirect(
183
+ 'dependency-resolver',
184
+ contract.assignee,
185
+ `Contract "${contract.contractId}" is ready to start (all dependencies satisfied)`,
186
+ { priority: 'normal' }
187
+ );
188
+ } catch (err) {
189
+ // If transition fails, log but continue
190
+ console.error(`Failed to transition contract ${contract.contractId} to ready:`, err.message);
191
+ }
192
+ }
193
+ }
194
+
195
+ // Step 5: Evaluate acceptance criteria for in_progress contracts
196
+ // Re-fetch in-progress contracts (some might have timed out in step 3)
197
+ const currentInProgress = this.contracts.list({ status: 'in_progress' });
198
+
199
+ for (const contract of currentInProgress) {
200
+ // Skip if autoComplete is disabled
201
+ if (!contract.autoComplete) {
202
+ continue;
203
+ }
204
+
205
+ // Check if ALL acceptance criteria are met
206
+ let allCriteriaMet = true;
207
+
208
+ // If there are no criteria, we can't auto-complete
209
+ if (contract.acceptanceCriteria.length === 0) {
210
+ continue;
211
+ }
212
+
213
+ // Loop through each criterion
214
+ for (const criterion of contract.acceptanceCriteria) {
215
+ const met = this._evaluateCriterion(criterion, contract);
216
+ if (!met) {
217
+ allCriteriaMet = false;
218
+ break; // No need to check more if one is not met
219
+ }
220
+ }
221
+
222
+ // If all criteria met, auto-complete the contract
223
+ if (allCriteriaMet) {
224
+ try {
225
+ this.contracts.complete(contract.contractId, {
226
+ summary: 'Auto-completed: all acceptance criteria met'
227
+ });
228
+
229
+ // Record this transition
230
+ transitions.push({
231
+ contractId: contract.contractId,
232
+ from: 'in_progress',
233
+ to: 'completed',
234
+ reason: 'auto_complete'
235
+ });
236
+
237
+ // Notify both assignee and assigner
238
+ this.teamHub.sendDirect(
239
+ 'dependency-resolver',
240
+ contract.assignee,
241
+ `Contract "${contract.contractId}" auto-completed (all criteria met)`,
242
+ { priority: 'normal' }
243
+ );
244
+
245
+ this.teamHub.sendDirect(
246
+ 'dependency-resolver',
247
+ contract.assigner,
248
+ `Contract "${contract.contractId}" completed by ${contract.assignee}`,
249
+ { priority: 'normal' }
250
+ );
251
+ } catch (err) {
252
+ console.error(`Failed to auto-complete contract ${contract.contractId}:`, err.message);
253
+ }
254
+ }
255
+ }
256
+
257
+ // Step 6: Handle cascading and notifications
258
+ if (transitions.length > 0) {
259
+ // Send summary notification to orchestrator if it exists
260
+ try {
261
+ this.teamHub.sendDirect(
262
+ 'dependency-resolver',
263
+ 'orchestrator',
264
+ `Resolution cycle complete: ${transitions.length} transition(s) at depth ${depth}`,
265
+ { priority: 'normal' }
266
+ );
267
+ } catch (err) {
268
+ // Orchestrator might not exist, that's okay
269
+ }
270
+
271
+ // If pipeline engine is configured, evaluate it for each transition
272
+ if (this.pipelineEngine) {
273
+ for (const transition of transitions) {
274
+ try {
275
+ await this.pipelineEngine.evaluate(transition);
276
+ } catch (err) {
277
+ console.error('Pipeline evaluation failed:', err.message);
278
+ }
279
+ }
280
+ }
281
+
282
+ // Cascade: recursively call resolve for any completed contracts
283
+ // This allows newly completed contracts to unlock other pending ones
284
+ const completedTransitions = transitions.filter(t => t.to === 'completed');
285
+
286
+ if (completedTransitions.length > 0) {
287
+ // Recursively resolve with increased depth
288
+ await this.resolve({ cascadeFrom: completedTransitions }, depth + 1);
289
+ }
290
+ }
291
+
292
+ // Step 7: Return results
293
+ return { transitions, depth };
294
+ }
295
+
296
+ /**
297
+ * Check if a single dependency is satisfied
298
+ *
299
+ * @private
300
+ * @param {Object} dep - The dependency object
301
+ * @param {string} dep.type - Type of dependency ('artifact' or 'contract')
302
+ * @param {string} dep.artifactId - Artifact ID (if type is 'artifact')
303
+ * @param {string} dep.contractId - Contract ID (if type is 'contract')
304
+ * @returns {boolean} True if dependency is satisfied, false otherwise
305
+ */
306
+ _checkDependency(dep) {
307
+ // Handle artifact dependency
308
+ if (dep.type === 'artifact') {
309
+ // Check if the artifact exists
310
+ const artifact = this.artifacts.get(dep.artifactId);
311
+ return artifact !== null;
312
+ }
313
+
314
+ // Handle contract dependency
315
+ if (dep.type === 'contract') {
316
+ // Check if the referenced contract is completed
317
+ const contract = this.contracts.get(dep.contractId);
318
+ return contract !== null && contract.status === 'completed';
319
+ }
320
+
321
+ // Unknown dependency type - consider it unsatisfied
322
+ return false;
323
+ }
324
+
325
+ /**
326
+ * Evaluate a single acceptance criterion
327
+ *
328
+ * @private
329
+ * @param {Object} criterion - The acceptance criterion object
330
+ * @param {string} criterion.type - Type of criterion
331
+ * @param {Object} contract - The contract being evaluated
332
+ * @returns {boolean} True if criterion is met, false otherwise
333
+ */
334
+ _evaluateCriterion(criterion, contract) {
335
+ // Handle 'artifact_published' criterion
336
+ // Checks if a specific artifact exists
337
+ if (criterion.type === 'artifact_published') {
338
+ const artifact = this.artifacts.get(criterion.artifactId);
339
+ return artifact !== null;
340
+ }
341
+
342
+ // Handle 'tests_passing' criterion
343
+ // Checks if test-results artifact shows no failures
344
+ if (criterion.type === 'tests_passing') {
345
+ // Look for a test-results artifact
346
+ const testArtifacts = this.artifacts.list({ type: 'test-results' });
347
+
348
+ // If no test results, criterion not met
349
+ if (testArtifacts.length === 0) {
350
+ return false;
351
+ }
352
+
353
+ // Get the latest test results
354
+ const latestTest = testArtifacts[testArtifacts.length - 1];
355
+ const testData = this.artifacts.get(latestTest.artifactId);
356
+
357
+ if (!testData || !testData.data) {
358
+ return false;
359
+ }
360
+
361
+ // Check if failures are zero (or within threshold if specified)
362
+ const threshold = criterion.maxFailures || 0;
363
+ return testData.data.failed <= threshold;
364
+ }
365
+
366
+ // Handle 'contract_completed' criterion
367
+ // Checks if another contract is completed
368
+ if (criterion.type === 'contract_completed') {
369
+ const referencedContract = this.contracts.get(criterion.contractId);
370
+ return referencedContract !== null && referencedContract.status === 'completed';
371
+ }
372
+
373
+ // Handle 'all_outputs_published' criterion
374
+ // Checks if all expected outputs have matching artifacts
375
+ if (criterion.type === 'all_outputs_published') {
376
+ // Get the expected outputs from the contract
377
+ const expectedOutputs = contract.expectedOutputs || [];
378
+
379
+ if (expectedOutputs.length === 0) {
380
+ // No outputs expected - criterion met
381
+ return true;
382
+ }
383
+
384
+ // Check each expected output
385
+ for (const output of expectedOutputs) {
386
+ // Each output should have an artifactId
387
+ if (output.artifactId) {
388
+ const artifact = this.artifacts.get(output.artifactId);
389
+ if (!artifact) {
390
+ return false; // Missing artifact
391
+ }
392
+ }
393
+ }
394
+
395
+ return true; // All outputs published
396
+ }
397
+
398
+ // Unknown criterion type - consider it not met
399
+ return false;
400
+ }
401
+
402
+ /**
403
+ * Transition a contract from pending to ready
404
+ * Uses locking to ensure thread-safety
405
+ *
406
+ * @private
407
+ * @param {string} contractId - The contract to transition
408
+ */
409
+ _transitionToReady(contractId) {
410
+ // Acquire lock on contracts index
411
+ acquireLock(this.locksDir, 'contracts-index');
412
+
413
+ try {
414
+ // Get the contract
415
+ const contract = this.contracts.get(contractId);
416
+
417
+ if (!contract) {
418
+ throw new Error(`Contract ${contractId} not found`);
419
+ }
420
+
421
+ // Verify it's in pending status
422
+ if (contract.status !== 'pending') {
423
+ throw new Error(`Contract ${contractId} is not pending (status: ${contract.status})`);
424
+ }
425
+
426
+ // Read the entire index
427
+ const { readJsonSafe, atomicWriteJson } = require('./atomic-io');
428
+ const indexPath = path.join(
429
+ os.homedir(),
430
+ '.clearctx',
431
+ 'team',
432
+ this.teamName,
433
+ 'contracts',
434
+ 'index.json'
435
+ );
436
+ const index = readJsonSafe(indexPath, {});
437
+
438
+ // Update the contract status
439
+ index[contractId].status = 'ready';
440
+ index[contractId].updatedAt = new Date().toISOString();
441
+
442
+ // Save the index
443
+ atomicWriteJson(indexPath, index);
444
+
445
+ } finally {
446
+ // Always release the lock
447
+ releaseLock(this.locksDir, 'contracts-index');
448
+ }
449
+ }
450
+ }
451
+
452
+ // Export the DependencyResolver class
453
+ module.exports = DependencyResolver;