@itz4blitz/agentful 1.2.0 → 1.3.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.
Files changed (59) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +11 -1055
  3. package/bin/hooks/block-file-creation.js +271 -0
  4. package/bin/hooks/product-spec-watcher.js +151 -0
  5. package/lib/index.js +0 -11
  6. package/lib/init.js +2 -21
  7. package/lib/parallel-execution.js +235 -0
  8. package/lib/presets.js +26 -4
  9. package/package.json +4 -7
  10. package/template/.claude/agents/architect.md +2 -2
  11. package/template/.claude/agents/backend.md +17 -30
  12. package/template/.claude/agents/frontend.md +17 -39
  13. package/template/.claude/agents/orchestrator.md +63 -4
  14. package/template/.claude/agents/product-analyzer.md +1 -1
  15. package/template/.claude/agents/tester.md +16 -29
  16. package/template/.claude/commands/agentful-generate.md +221 -14
  17. package/template/.claude/commands/agentful-init.md +621 -0
  18. package/template/.claude/commands/agentful-product.md +1 -1
  19. package/template/.claude/commands/agentful-start.md +99 -1
  20. package/template/.claude/product/EXAMPLES.md +2 -2
  21. package/template/.claude/product/index.md +1 -1
  22. package/template/.claude/settings.json +22 -0
  23. package/template/.claude/skills/research/SKILL.md +432 -0
  24. package/template/CLAUDE.md +5 -6
  25. package/template/bin/hooks/architect-drift-detector.js +242 -0
  26. package/template/bin/hooks/product-spec-watcher.js +151 -0
  27. package/version.json +1 -1
  28. package/bin/hooks/post-agent.js +0 -101
  29. package/bin/hooks/post-feature.js +0 -227
  30. package/bin/hooks/pre-agent.js +0 -118
  31. package/bin/hooks/pre-feature.js +0 -138
  32. package/lib/VALIDATION_README.md +0 -455
  33. package/lib/ci/claude-action-integration.js +0 -641
  34. package/lib/ci/index.js +0 -10
  35. package/lib/core/analyzer.js +0 -497
  36. package/lib/core/cli.js +0 -141
  37. package/lib/core/detectors/conventions.js +0 -342
  38. package/lib/core/detectors/framework.js +0 -276
  39. package/lib/core/detectors/index.js +0 -15
  40. package/lib/core/detectors/language.js +0 -199
  41. package/lib/core/detectors/patterns.js +0 -356
  42. package/lib/core/generator.js +0 -626
  43. package/lib/core/index.js +0 -9
  44. package/lib/core/output-parser.js +0 -458
  45. package/lib/core/storage.js +0 -515
  46. package/lib/core/templates.js +0 -556
  47. package/lib/pipeline/cli.js +0 -423
  48. package/lib/pipeline/engine.js +0 -928
  49. package/lib/pipeline/executor.js +0 -440
  50. package/lib/pipeline/index.js +0 -33
  51. package/lib/pipeline/integrations.js +0 -559
  52. package/lib/pipeline/schemas.js +0 -288
  53. package/lib/remote/client.js +0 -361
  54. package/lib/server/auth.js +0 -270
  55. package/lib/server/client-example.js +0 -190
  56. package/lib/server/executor.js +0 -477
  57. package/lib/server/index.js +0 -494
  58. package/lib/update-helpers.js +0 -505
  59. package/lib/validation.js +0 -460
@@ -1,928 +0,0 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import { EventEmitter } from 'events';
4
- import yaml from 'js-yaml';
5
- import { atomicWrite, atomicUpdate } from '../atomic.js';
6
-
7
- /**
8
- * Pipeline Orchestration Engine
9
- *
10
- * Manages async, long-running AI agent workflows with:
11
- * - Dependency graph resolution
12
- * - Parallel and sequential execution
13
- * - State persistence and recovery
14
- * - Resource management
15
- * - Progress tracking
16
- */
17
-
18
- /**
19
- * Job Status States
20
- */
21
- export const JobStatus = {
22
- PENDING: 'pending',
23
- QUEUED: 'queued',
24
- RUNNING: 'running',
25
- COMPLETED: 'completed',
26
- FAILED: 'failed',
27
- CANCELLED: 'cancelled',
28
- SKIPPED: 'skipped'
29
- };
30
-
31
- /**
32
- * Pipeline Status States
33
- */
34
- export const PipelineStatus = {
35
- IDLE: 'idle',
36
- RUNNING: 'running',
37
- PAUSED: 'paused',
38
- COMPLETED: 'completed',
39
- FAILED: 'failed',
40
- CANCELLED: 'cancelled'
41
- };
42
-
43
- /**
44
- * Pipeline Orchestration Engine
45
- *
46
- * @extends EventEmitter
47
- *
48
- * Events emitted:
49
- * - 'pipeline:started' - Pipeline execution started
50
- * - 'pipeline:completed' - Pipeline completed successfully
51
- * - 'pipeline:failed' - Pipeline failed
52
- * - 'pipeline:cancelled' - Pipeline was cancelled
53
- * - 'job:started' - Job started execution
54
- * - 'job:completed' - Job completed successfully
55
- * - 'job:failed' - Job failed
56
- * - 'job:progress' - Job progress update
57
- * - 'job:log' - Job log output
58
- */
59
- export class PipelineEngine extends EventEmitter {
60
- constructor(options = {}) {
61
- super();
62
-
63
- this.options = {
64
- maxConcurrentJobs: options.maxConcurrentJobs || 3,
65
- stateDir: options.stateDir || '.agentful/pipelines',
66
- defaultTimeout: options.defaultTimeout || 30 * 60 * 1000, // 30 minutes
67
- retryDelayMs: options.retryDelayMs || 2000,
68
- enablePersistence: options.enablePersistence !== false,
69
- ...options
70
- };
71
-
72
- // Runtime state
73
- this.pipelines = new Map(); // pipelineId -> PipelineState
74
- this.runningJobs = new Map(); // jobId -> JobExecution
75
- this.jobQueue = []; // Array of QueuedJob
76
-
77
- // Resource tracking
78
- this.activeJobCount = 0;
79
-
80
- // Track pending timers for cleanup
81
- this.pendingTimers = new Set();
82
-
83
- // Track active pipeline executions
84
- this.executionPromises = new Map();
85
-
86
- // Abort controller for cancelling background operations
87
- this.abortController = new AbortController();
88
- }
89
-
90
- /**
91
- * Load pipeline definition from YAML file
92
- *
93
- * @param {string} pipelineFile - Path to pipeline YAML file
94
- * @returns {Promise<Object>} Pipeline definition
95
- */
96
- async loadPipeline(pipelineFile) {
97
- const content = await fs.readFile(pipelineFile, 'utf-8');
98
- const pipeline = yaml.load(content);
99
-
100
- // Validate pipeline definition
101
- this._validatePipeline(pipeline);
102
-
103
- return pipeline;
104
- }
105
-
106
- /**
107
- * Start pipeline execution
108
- *
109
- * @param {Object|string} pipelineOrFile - Pipeline definition object or path to YAML file
110
- * @param {Object} context - Initial execution context (variables, inputs)
111
- * @returns {Promise<string>} Pipeline run ID
112
- */
113
- async startPipeline(pipelineOrFile, context = {}) {
114
- // Load pipeline if file path provided
115
- const pipeline = typeof pipelineOrFile === 'string'
116
- ? await this.loadPipeline(pipelineOrFile)
117
- : pipelineOrFile;
118
-
119
- // Generate unique run ID
120
- const runId = this._generateRunId(pipeline.name);
121
-
122
- // Initialize pipeline state
123
- const pipelineState = {
124
- runId,
125
- pipeline,
126
- status: PipelineStatus.RUNNING,
127
- startedAt: new Date().toISOString(),
128
- completedAt: null,
129
- context: { ...context },
130
- jobs: this._initializeJobStates(pipeline.jobs),
131
- dependencyGraph: this._buildDependencyGraph(pipeline.jobs),
132
- errors: [],
133
- metadata: {
134
- pipelineName: pipeline.name,
135
- pipelineVersion: pipeline.version || '1.0',
136
- triggeredBy: context.triggeredBy || 'manual'
137
- }
138
- };
139
-
140
- this.pipelines.set(runId, pipelineState);
141
-
142
- // Persist initial state
143
- if (this.options.enablePersistence) {
144
- await this._persistPipelineState(runId, pipelineState);
145
- }
146
-
147
- // Emit start event
148
- this.emit('pipeline:started', {
149
- runId,
150
- pipeline: pipeline.name,
151
- context
152
- });
153
-
154
- // Begin execution (non-blocking)
155
- const executionPromise = this._executePipeline(runId).catch(error => {
156
- console.error(`Pipeline ${runId} execution failed:`, error);
157
- this.emit('pipeline:failed', { runId, error: error.message });
158
- });
159
-
160
- this.executionPromises.set(runId, executionPromise);
161
-
162
- return runId;
163
- }
164
-
165
- /**
166
- * Get pipeline execution status
167
- *
168
- * @param {string} runId - Pipeline run ID
169
- * @returns {Object|null} Pipeline state or null if not found
170
- */
171
- getPipelineStatus(runId) {
172
- const state = this.pipelines.get(runId);
173
- if (!state) return null;
174
-
175
- return {
176
- runId: state.runId,
177
- pipeline: state.pipeline.name,
178
- status: state.status,
179
- startedAt: state.startedAt,
180
- completedAt: state.completedAt,
181
- progress: this._calculateProgress(state),
182
- jobs: Object.entries(state.jobs).map(([jobId, job]) => ({
183
- id: jobId,
184
- name: job.name,
185
- status: job.status,
186
- progress: job.progress,
187
- startedAt: job.startedAt,
188
- completedAt: job.completedAt,
189
- error: job.error
190
- })),
191
- errors: state.errors
192
- };
193
- }
194
-
195
- /**
196
- * Cancel pipeline execution
197
- *
198
- * @param {string} runId - Pipeline run ID
199
- * @returns {Promise<boolean>} True if cancelled, false if not found
200
- */
201
- async cancelPipeline(runId) {
202
- const state = this.pipelines.get(runId);
203
- if (!state) return false;
204
-
205
- // Mark pipeline as cancelled
206
- state.status = PipelineStatus.CANCELLED;
207
- state.completedAt = new Date().toISOString();
208
-
209
- // Cancel all running and queued jobs
210
- for (const [jobId, job] of Object.entries(state.jobs)) {
211
- if (job.status === JobStatus.RUNNING || job.status === JobStatus.QUEUED) {
212
- job.status = JobStatus.CANCELLED;
213
- job.completedAt = new Date().toISOString();
214
-
215
- // Cancel active job execution
216
- const execution = this.runningJobs.get(jobId);
217
- if (execution) {
218
- await this._cancelJobExecution(execution);
219
- this.runningJobs.delete(jobId);
220
- this.activeJobCount--;
221
- }
222
- }
223
- }
224
-
225
- // Remove from queue
226
- this.jobQueue = this.jobQueue.filter(q => q.runId !== runId);
227
-
228
- // Persist state
229
- if (this.options.enablePersistence) {
230
- await this._persistPipelineState(runId, state);
231
- }
232
-
233
- this.emit('pipeline:cancelled', { runId });
234
- return true;
235
- }
236
-
237
- /**
238
- * Resume a previously interrupted pipeline
239
- *
240
- * @param {string} runId - Pipeline run ID to resume
241
- * @returns {Promise<boolean>} True if resumed, false if not found or not resumable
242
- */
243
- async resumePipeline(runId) {
244
- // Try to load from persistence
245
- const state = await this._loadPipelineState(runId);
246
- if (!state) return false;
247
-
248
- // Only resume if pipeline was interrupted
249
- if (state.status !== PipelineStatus.RUNNING && state.status !== PipelineStatus.PAUSED) {
250
- return false;
251
- }
252
-
253
- // Restore state
254
- this.pipelines.set(runId, state);
255
-
256
- // Reset failed/running jobs to pending
257
- for (const [jobId, job] of Object.entries(state.jobs)) {
258
- if (job.status === JobStatus.RUNNING || job.status === JobStatus.FAILED) {
259
- job.status = JobStatus.PENDING;
260
- job.error = null;
261
- job.attemptCount = 0;
262
- }
263
- }
264
-
265
- // Resume execution
266
- this.emit('pipeline:resumed', { runId });
267
- this._executePipeline(runId).catch(error => {
268
- console.error(`Pipeline ${runId} execution failed:`, error);
269
- this.emit('pipeline:failed', { runId, error: error.message });
270
- });
271
-
272
- return true;
273
- }
274
-
275
- /**
276
- * Internal: Execute pipeline
277
- *
278
- * @private
279
- */
280
- async _executePipeline(runId) {
281
- const state = this.pipelines.get(runId);
282
- if (!state) return;
283
-
284
- try {
285
- // Execute jobs based on dependency graph
286
- await this._executeJobsInOrder(state);
287
-
288
- // Check if pipeline completed successfully
289
- const allCompleted = Object.values(state.jobs).every(
290
- job => job.status === JobStatus.COMPLETED || job.status === JobStatus.SKIPPED
291
- );
292
-
293
- if (allCompleted) {
294
- state.status = PipelineStatus.COMPLETED;
295
- state.completedAt = new Date().toISOString();
296
- this.emit('pipeline:completed', { runId, duration: this._calculateDuration(state) });
297
- } else {
298
- state.status = PipelineStatus.FAILED;
299
- state.completedAt = new Date().toISOString();
300
- this.emit('pipeline:failed', {
301
- runId,
302
- error: 'Some jobs did not complete successfully'
303
- });
304
- }
305
- } catch (error) {
306
- state.status = PipelineStatus.FAILED;
307
- state.completedAt = new Date().toISOString();
308
- state.errors.push({
309
- type: 'pipeline_execution',
310
- message: error.message,
311
- timestamp: new Date().toISOString()
312
- });
313
-
314
- this.emit('pipeline:failed', { runId, error: error.message });
315
- } finally {
316
- // Persist final state
317
- if (this.options.enablePersistence) {
318
- await this._persistPipelineState(runId, state);
319
- }
320
- }
321
- }
322
-
323
- /**
324
- * Internal: Execute jobs in dependency order
325
- *
326
- * @private
327
- */
328
- async _executeJobsInOrder(state) {
329
- const { jobs, dependencyGraph } = state;
330
-
331
- while (true) {
332
- // Check if aborted
333
- if (this.abortController.signal.aborted) {
334
- throw new Error('Pipeline execution aborted');
335
- }
336
-
337
- // Find jobs ready to execute (dependencies met)
338
- const readyJobs = this._findReadyJobs(jobs, dependencyGraph);
339
-
340
- if (readyJobs.length === 0) {
341
- // Check if all jobs are done
342
- const pendingJobs = Object.values(jobs).filter(
343
- job => job.status === JobStatus.PENDING || job.status === JobStatus.QUEUED
344
- );
345
-
346
- if (pendingJobs.length === 0) {
347
- break; // All done
348
- }
349
-
350
- // Wait for running jobs to complete
351
- try {
352
- await this._waitForJobSlot();
353
- } catch (error) {
354
- if (this.abortController.signal.aborted) {
355
- throw new Error('Pipeline execution aborted');
356
- }
357
- throw error;
358
- }
359
- continue;
360
- }
361
-
362
- // Queue ready jobs
363
- for (const jobId of readyJobs) {
364
- jobs[jobId].status = JobStatus.QUEUED;
365
- this.jobQueue.push({
366
- runId: state.runId,
367
- jobId,
368
- queuedAt: Date.now()
369
- });
370
- }
371
-
372
- // Process job queue (respecting concurrency limits)
373
- await this._processJobQueue(state);
374
-
375
- // Small delay to prevent tight loop
376
- await new Promise(resolve => {
377
- const timerId = setTimeout(resolve, 100);
378
- this.pendingTimers.add(timerId);
379
- });
380
- }
381
-
382
- // Wait for all remaining jobs to complete
383
- while (this.activeJobCount > 0) {
384
- await this._waitForJobSlot();
385
- }
386
- }
387
-
388
- /**
389
- * Internal: Find jobs ready to execute
390
- *
391
- * @private
392
- */
393
- _findReadyJobs(jobs, dependencyGraph) {
394
- const ready = [];
395
-
396
- for (const [jobId, job] of Object.entries(jobs)) {
397
- if (job.status !== JobStatus.PENDING) continue;
398
-
399
- // Check if all dependencies are completed
400
- const dependencies = dependencyGraph[jobId] || [];
401
- const allDepsCompleted = dependencies.every(depId => {
402
- const depJob = jobs[depId];
403
- return depJob.status === JobStatus.COMPLETED || depJob.status === JobStatus.SKIPPED;
404
- });
405
-
406
- if (allDepsCompleted) {
407
- // Check conditional execution
408
- if (job.when && !this._evaluateCondition(job.when, jobs)) {
409
- job.status = JobStatus.SKIPPED;
410
- job.completedAt = new Date().toISOString();
411
- continue;
412
- }
413
-
414
- ready.push(jobId);
415
- }
416
- }
417
-
418
- return ready;
419
- }
420
-
421
- /**
422
- * Internal: Process job queue
423
- *
424
- * @private
425
- */
426
- async _processJobQueue(state) {
427
- while (this.jobQueue.length > 0 && this.activeJobCount < this.options.maxConcurrentJobs) {
428
- const queuedJob = this.jobQueue.shift();
429
- if (!queuedJob) break;
430
-
431
- // Verify job still needs to run
432
- const job = state.jobs[queuedJob.jobId];
433
- if (!job || job.status !== JobStatus.QUEUED) continue;
434
-
435
- // Start job execution
436
- this.activeJobCount++;
437
- this._executeJob(state, queuedJob.jobId).finally(() => {
438
- this.activeJobCount--;
439
- });
440
- }
441
- }
442
-
443
- /**
444
- * Internal: Execute a single job
445
- *
446
- * @private
447
- */
448
- async _executeJob(state, jobId) {
449
- const job = state.jobs[jobId];
450
- const jobDef = state.pipeline.jobs.find(j => j.id === jobId);
451
-
452
- job.status = JobStatus.RUNNING;
453
- job.startedAt = new Date().toISOString();
454
- job.attemptCount = (job.attemptCount || 0) + 1;
455
-
456
- this.emit('job:started', {
457
- runId: state.runId,
458
- jobId,
459
- jobName: job.name,
460
- attempt: job.attemptCount
461
- });
462
-
463
- try {
464
- // Build job context (includes outputs from dependencies)
465
- const jobContext = await this._buildJobContext(state, jobId);
466
-
467
- // Execute agent
468
- const result = await this._executeAgent(jobDef, jobContext, {
469
- onProgress: (progress) => {
470
- job.progress = progress;
471
- this.emit('job:progress', { runId: state.runId, jobId, progress });
472
- },
473
- onLog: (message) => {
474
- job.logs = job.logs || [];
475
- job.logs.push({ timestamp: new Date().toISOString(), message });
476
- this.emit('job:log', { runId: state.runId, jobId, message });
477
- },
478
- timeout: jobDef.timeout || this.options.defaultTimeout
479
- });
480
-
481
- // Store result
482
- job.status = JobStatus.COMPLETED;
483
- job.completedAt = new Date().toISOString();
484
- job.output = result.output;
485
- job.duration = Date.now() - new Date(job.startedAt).getTime();
486
-
487
- this.emit('job:completed', {
488
- runId: state.runId,
489
- jobId,
490
- jobName: job.name,
491
- duration: job.duration,
492
- output: result.output
493
- });
494
-
495
- } catch (error) {
496
- job.error = error.message;
497
- job.errorStack = error.stack;
498
-
499
- // Check if should retry
500
- const maxRetries = jobDef.retry?.maxAttempts || 0;
501
- if (job.attemptCount <= maxRetries) {
502
- // Retry job
503
- job.status = JobStatus.PENDING;
504
- job.error = null;
505
-
506
- const delay = this._calculateRetryDelay(job.attemptCount, jobDef.retry);
507
- await new Promise(resolve => setTimeout(resolve, delay));
508
-
509
- this.emit('job:retry', {
510
- runId: state.runId,
511
- jobId,
512
- jobName: job.name,
513
- attempt: job.attemptCount
514
- });
515
- } else {
516
- // Job failed
517
- job.status = JobStatus.FAILED;
518
- job.completedAt = new Date().toISOString();
519
-
520
- state.errors.push({
521
- type: 'job_execution',
522
- jobId,
523
- jobName: job.name,
524
- message: error.message,
525
- timestamp: new Date().toISOString()
526
- });
527
-
528
- this.emit('job:failed', {
529
- runId: state.runId,
530
- jobId,
531
- jobName: job.name,
532
- error: error.message,
533
- attempts: job.attemptCount
534
- });
535
-
536
- // Check if should continue or fail fast
537
- if (jobDef.continueOnError !== true) {
538
- throw new Error(`Job ${jobId} failed: ${error.message}`);
539
- }
540
- }
541
- } finally {
542
- // Persist state after each job
543
- if (this.options.enablePersistence) {
544
- await this._persistPipelineState(state.runId, state);
545
- }
546
- }
547
- }
548
-
549
- /**
550
- * Internal: Execute agent for a job
551
- *
552
- * @private
553
- */
554
- async _executeAgent(jobDef, context, options) {
555
- // This is the integration point with agentful's agent system
556
- // For now, we'll create a placeholder that can be overridden
557
-
558
- if (this.options.agentExecutor) {
559
- return await this.options.agentExecutor(jobDef, context, options);
560
- }
561
-
562
- // Default implementation - would integrate with Claude Code Task API
563
- throw new Error('Agent executor not configured. Set options.agentExecutor');
564
- }
565
-
566
- /**
567
- * Internal: Build job context from dependencies
568
- *
569
- * @private
570
- */
571
- async _buildJobContext(state, jobId) {
572
- const jobDef = state.pipeline.jobs.find(j => j.id === jobId);
573
- const context = { ...state.context };
574
-
575
- // Add outputs from dependencies
576
- const dependencies = state.dependencyGraph[jobId] || [];
577
- for (const depId of dependencies) {
578
- const depJob = state.jobs[depId];
579
- if (depJob.status === JobStatus.COMPLETED && depJob.output) {
580
- context[depId] = depJob.output;
581
- }
582
- }
583
-
584
- // Add job-specific inputs
585
- if (jobDef.inputs) {
586
- context.inputs = jobDef.inputs;
587
- }
588
-
589
- return context;
590
- }
591
-
592
- /**
593
- * Internal: Validate pipeline definition
594
- *
595
- * @private
596
- */
597
- _validatePipeline(pipeline) {
598
- if (!pipeline.name) {
599
- throw new Error('Pipeline must have a name');
600
- }
601
-
602
- if (!pipeline.jobs || !Array.isArray(pipeline.jobs) || pipeline.jobs.length === 0) {
603
- throw new Error('Pipeline must have at least one job');
604
- }
605
-
606
- // Validate each job - first pass: collect job IDs
607
- const jobIds = new Set();
608
- for (const job of pipeline.jobs) {
609
- if (!job.id) {
610
- throw new Error('Each job must have an id');
611
- }
612
-
613
- if (jobIds.has(job.id)) {
614
- throw new Error(`Duplicate job id: ${job.id}`);
615
- }
616
- jobIds.add(job.id);
617
-
618
- if (!job.agent) {
619
- throw new Error(`Job ${job.id} must specify an agent`);
620
- }
621
- }
622
-
623
- // Second pass: validate dependencies exist (after all job IDs are collected)
624
- for (const job of pipeline.jobs) {
625
- if (job.dependsOn) {
626
- const deps = Array.isArray(job.dependsOn) ? job.dependsOn : [job.dependsOn];
627
- for (const depId of deps) {
628
- if (!jobIds.has(depId)) {
629
- throw new Error(`Job ${job.id} depends on unknown job: ${depId}`);
630
- }
631
- }
632
- }
633
- }
634
-
635
- // Check for circular dependencies
636
- this._detectCircularDependencies(pipeline.jobs);
637
- }
638
-
639
- /**
640
- * Internal: Build dependency graph
641
- *
642
- * @private
643
- */
644
- _buildDependencyGraph(jobs) {
645
- const graph = {};
646
-
647
- for (const job of jobs) {
648
- const deps = job.dependsOn
649
- ? (Array.isArray(job.dependsOn) ? job.dependsOn : [job.dependsOn])
650
- : [];
651
-
652
- graph[job.id] = deps;
653
- }
654
-
655
- return graph;
656
- }
657
-
658
- /**
659
- * Internal: Initialize job states
660
- *
661
- * @private
662
- */
663
- _initializeJobStates(jobs) {
664
- const states = {};
665
-
666
- for (const job of jobs) {
667
- states[job.id] = {
668
- id: job.id,
669
- name: job.name || job.id,
670
- status: JobStatus.PENDING,
671
- progress: 0,
672
- startedAt: null,
673
- completedAt: null,
674
- duration: null,
675
- output: null,
676
- error: null,
677
- errorStack: null,
678
- attemptCount: 0,
679
- logs: []
680
- };
681
- }
682
-
683
- return states;
684
- }
685
-
686
- /**
687
- * Internal: Detect circular dependencies
688
- *
689
- * @private
690
- */
691
- _detectCircularDependencies(jobs) {
692
- const graph = this._buildDependencyGraph(jobs);
693
- const visited = new Set();
694
- const recursionStack = new Set();
695
-
696
- const hasCycle = (jobId) => {
697
- visited.add(jobId);
698
- recursionStack.add(jobId);
699
-
700
- const dependencies = graph[jobId] || [];
701
- for (const depId of dependencies) {
702
- if (!visited.has(depId)) {
703
- if (hasCycle(depId)) return true;
704
- } else if (recursionStack.has(depId)) {
705
- return true;
706
- }
707
- }
708
-
709
- recursionStack.delete(jobId);
710
- return false;
711
- };
712
-
713
- for (const job of jobs) {
714
- if (!visited.has(job.id)) {
715
- if (hasCycle(job.id)) {
716
- throw new Error('Circular dependency detected in pipeline');
717
- }
718
- }
719
- }
720
- }
721
-
722
- /**
723
- * Internal: Evaluate conditional expression
724
- *
725
- * @private
726
- */
727
- _evaluateCondition(condition, jobs) {
728
- // Simple condition evaluation
729
- // Supports: "job.status == 'completed'", "job.output.success == true"
730
-
731
- try {
732
- // Replace job references with actual values
733
- const conditionStr = condition.replace(/(\w+)\.(\w+)/g, (match, jobId, prop) => {
734
- const job = jobs[jobId];
735
- if (!job) return 'undefined';
736
-
737
- if (prop === 'status') return `'${job.status}'`;
738
- if (prop === 'output' && job.output) return JSON.stringify(job.output);
739
-
740
- return 'undefined';
741
- });
742
-
743
- // Evaluate (using Function to avoid eval)
744
- return new Function(`return ${conditionStr}`)();
745
- } catch (error) {
746
- console.error(`Failed to evaluate condition: ${condition}`, error);
747
- return false;
748
- }
749
- }
750
-
751
- /**
752
- * Internal: Calculate retry delay
753
- *
754
- * @private
755
- */
756
- _calculateRetryDelay(attemptCount, retryConfig) {
757
- if (!retryConfig) return this.options.retryDelayMs;
758
-
759
- const strategy = retryConfig.backoff || 'exponential';
760
- const baseDelay = retryConfig.delayMs || this.options.retryDelayMs;
761
-
762
- if (strategy === 'exponential') {
763
- return baseDelay * Math.pow(2, attemptCount - 1);
764
- } else if (strategy === 'linear') {
765
- return baseDelay * attemptCount;
766
- } else {
767
- return baseDelay;
768
- }
769
- }
770
-
771
- /**
772
- * Internal: Calculate pipeline progress
773
- *
774
- * @private
775
- */
776
- _calculateProgress(state) {
777
- const jobs = Object.values(state.jobs);
778
- const totalJobs = jobs.length;
779
- const completedJobs = jobs.filter(
780
- j => j.status === JobStatus.COMPLETED || j.status === JobStatus.SKIPPED
781
- ).length;
782
-
783
- return Math.round((completedJobs / totalJobs) * 100);
784
- }
785
-
786
- /**
787
- * Internal: Calculate pipeline duration
788
- *
789
- * @private
790
- */
791
- _calculateDuration(state) {
792
- if (!state.startedAt) return 0;
793
-
794
- const endTime = state.completedAt ? new Date(state.completedAt) : new Date();
795
- const startTime = new Date(state.startedAt);
796
-
797
- return endTime - startTime;
798
- }
799
-
800
- /**
801
- * Internal: Generate unique run ID
802
- *
803
- * @private
804
- */
805
- _generateRunId(pipelineName) {
806
- const timestamp = Date.now();
807
- const random = Math.random().toString(36).substring(2, 9);
808
- return `${pipelineName}-${timestamp}-${random}`;
809
- }
810
-
811
- /**
812
- * Internal: Persist pipeline state to disk
813
- *
814
- * @private
815
- */
816
- async _persistPipelineState(runId, state) {
817
- const stateFile = path.join(this.options.stateDir, 'runs', `${runId}.json`);
818
- await fs.mkdir(path.dirname(stateFile), { recursive: true });
819
- await atomicWrite(stateFile, JSON.stringify(state, null, 2));
820
- }
821
-
822
- /**
823
- * Internal: Load pipeline state from disk
824
- *
825
- * @private
826
- */
827
- async _loadPipelineState(runId) {
828
- const stateFile = path.join(this.options.stateDir, 'runs', `${runId}.json`);
829
-
830
- try {
831
- const content = await fs.readFile(stateFile, 'utf-8');
832
- return JSON.parse(content);
833
- } catch (error) {
834
- if (error.code === 'ENOENT') return null;
835
- throw error;
836
- }
837
- }
838
-
839
- /**
840
- * Internal: Wait for a job slot to become available
841
- *
842
- * @private
843
- */
844
- async _waitForJobSlot() {
845
- return new Promise((resolve, reject) => {
846
- // Check if already aborted
847
- if (this.abortController.signal.aborted) {
848
- reject(new Error('Operation aborted'));
849
- return;
850
- }
851
-
852
- const checkSlot = () => {
853
- if (this.abortController.signal.aborted) {
854
- reject(new Error('Operation aborted'));
855
- return;
856
- }
857
-
858
- if (this.activeJobCount < this.options.maxConcurrentJobs) {
859
- resolve();
860
- } else {
861
- const timerId = setTimeout(() => {
862
- this.pendingTimers.delete(timerId);
863
- checkSlot();
864
- }, 500);
865
- this.pendingTimers.add(timerId);
866
- }
867
- };
868
- checkSlot();
869
- });
870
- }
871
-
872
- /**
873
- * Wait for all pipeline executions to complete
874
- * Useful for testing to ensure all background operations finish
875
- */
876
- async waitForAll() {
877
- const promises = Array.from(this.executionPromises.values());
878
- if (promises.length > 0) {
879
- await Promise.all(promises);
880
- }
881
- }
882
-
883
- /**
884
- * Cleanup method to clear all pending timers
885
- * Call this in test cleanup to prevent hanging
886
- */
887
- async cleanup() {
888
- // Abort all background operations
889
- this.abortController.abort();
890
-
891
- // Wait for all executions to complete first (they will abort quickly)
892
- await Promise.allSettled(Array.from(this.executionPromises.values()));
893
-
894
- // Clear all pending timers
895
- for (const timerId of this.pendingTimers) {
896
- clearTimeout(timerId);
897
- }
898
- this.pendingTimers.clear();
899
-
900
- // Remove all event listeners
901
- this.removeAllListeners();
902
-
903
- // Clear internal state
904
- this.pipelines.clear();
905
- this.runningJobs.clear();
906
- this.jobQueue = [];
907
- this.activeJobCount = 0;
908
- this.executionPromises.clear();
909
-
910
- // Reset abort controller for potential reuse
911
- this.abortController = new AbortController();
912
- }
913
-
914
- /**
915
- * Internal: Cancel job execution
916
- *
917
- * @private
918
- */
919
- async _cancelJobExecution(execution) {
920
- // Implementation depends on agent executor
921
- // For now, just mark as cancelled
922
- if (execution.cancel) {
923
- await execution.cancel();
924
- }
925
- }
926
- }
927
-
928
- export default PipelineEngine;