@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.
- package/README.md +28 -1
- package/bin/cli.js +11 -1055
- package/bin/hooks/block-file-creation.js +271 -0
- package/bin/hooks/product-spec-watcher.js +151 -0
- package/lib/index.js +0 -11
- package/lib/init.js +2 -21
- package/lib/parallel-execution.js +235 -0
- package/lib/presets.js +26 -4
- package/package.json +4 -7
- package/template/.claude/agents/architect.md +2 -2
- package/template/.claude/agents/backend.md +17 -30
- package/template/.claude/agents/frontend.md +17 -39
- package/template/.claude/agents/orchestrator.md +63 -4
- package/template/.claude/agents/product-analyzer.md +1 -1
- package/template/.claude/agents/tester.md +16 -29
- package/template/.claude/commands/agentful-generate.md +221 -14
- package/template/.claude/commands/agentful-init.md +621 -0
- package/template/.claude/commands/agentful-product.md +1 -1
- package/template/.claude/commands/agentful-start.md +99 -1
- package/template/.claude/product/EXAMPLES.md +2 -2
- package/template/.claude/product/index.md +1 -1
- package/template/.claude/settings.json +22 -0
- package/template/.claude/skills/research/SKILL.md +432 -0
- package/template/CLAUDE.md +5 -6
- package/template/bin/hooks/architect-drift-detector.js +242 -0
- package/template/bin/hooks/product-spec-watcher.js +151 -0
- package/version.json +1 -1
- package/bin/hooks/post-agent.js +0 -101
- package/bin/hooks/post-feature.js +0 -227
- package/bin/hooks/pre-agent.js +0 -118
- package/bin/hooks/pre-feature.js +0 -138
- package/lib/VALIDATION_README.md +0 -455
- package/lib/ci/claude-action-integration.js +0 -641
- package/lib/ci/index.js +0 -10
- package/lib/core/analyzer.js +0 -497
- package/lib/core/cli.js +0 -141
- package/lib/core/detectors/conventions.js +0 -342
- package/lib/core/detectors/framework.js +0 -276
- package/lib/core/detectors/index.js +0 -15
- package/lib/core/detectors/language.js +0 -199
- package/lib/core/detectors/patterns.js +0 -356
- package/lib/core/generator.js +0 -626
- package/lib/core/index.js +0 -9
- package/lib/core/output-parser.js +0 -458
- package/lib/core/storage.js +0 -515
- package/lib/core/templates.js +0 -556
- package/lib/pipeline/cli.js +0 -423
- package/lib/pipeline/engine.js +0 -928
- package/lib/pipeline/executor.js +0 -440
- package/lib/pipeline/index.js +0 -33
- package/lib/pipeline/integrations.js +0 -559
- package/lib/pipeline/schemas.js +0 -288
- package/lib/remote/client.js +0 -361
- package/lib/server/auth.js +0 -270
- package/lib/server/client-example.js +0 -190
- package/lib/server/executor.js +0 -477
- package/lib/server/index.js +0 -494
- package/lib/update-helpers.js +0 -505
- package/lib/validation.js +0 -460
package/lib/pipeline/engine.js
DELETED
|
@@ -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;
|