shipdeck
Version:
Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.
590 lines (514 loc) • 16.1 kB
JavaScript
/**
* Real-time Progress Tracker for DAG Workflows
* Provides detailed progress monitoring, metrics, and real-time updates
*/
const EventEmitter = require('events');
/**
* Progress tracking for individual workflow nodes
*/
class NodeProgressTracker {
constructor(nodeId, nodeName) {
this.nodeId = nodeId;
this.nodeName = nodeName;
this.status = 'pending';
this.startedAt = null;
this.completedAt = null;
this.duration = null;
this.attempts = 0;
this.progress = 0; // 0-100
this.estimatedCompletion = null;
this.metrics = {
tokensUsed: 0,
cost: 0,
errors: []
};
}
/**
* Update node progress
*/
updateProgress(status, progress = 0, metadata = {}) {
this.status = status;
this.progress = Math.max(0, Math.min(100, progress));
if (status === 'running' && !this.startedAt) {
this.startedAt = new Date();
this.estimatedCompletion = this.calculateEstimatedCompletion(metadata.timeout);
}
if (['completed', 'failed', 'skipped'].includes(status)) {
this.completedAt = new Date();
this.duration = this.startedAt ? this.completedAt - this.startedAt : 0;
this.progress = status === 'completed' ? 100 : this.progress;
}
if (metadata.attempts) this.attempts = metadata.attempts;
if (metadata.tokensUsed) this.metrics.tokensUsed = metadata.tokensUsed;
if (metadata.cost) this.metrics.cost = metadata.cost;
if (metadata.error) this.metrics.errors.push(metadata.error);
}
/**
* Calculate estimated completion time
*/
calculateEstimatedCompletion(timeoutMs = 300000) {
if (!this.startedAt) return null;
// Use timeout as base estimate, adjust based on progress
const baseEstimate = timeoutMs;
const adjustedEstimate = this.progress > 0
? (baseEstimate * (100 - this.progress)) / 100
: baseEstimate;
return new Date(this.startedAt.getTime() + adjustedEstimate);
}
/**
* Get progress summary
*/
getSummary() {
return {
nodeId: this.nodeId,
name: this.nodeName,
status: this.status,
progress: this.progress,
duration: this.duration,
attempts: this.attempts,
startedAt: this.startedAt,
completedAt: this.completedAt,
estimatedCompletion: this.estimatedCompletion,
metrics: { ...this.metrics }
};
}
}
/**
* Main workflow progress tracker
*/
class WorkflowProgressTracker extends EventEmitter {
constructor(workflowId, workflowName, totalNodes) {
super();
this.workflowId = workflowId;
this.workflowName = workflowName;
this.totalNodes = totalNodes;
this.nodeTrackers = new Map();
this.startedAt = null;
this.completedAt = null;
this.status = 'pending';
// Progress metrics
this.completedNodes = 0;
this.failedNodes = 0;
this.skippedNodes = 0;
this.runningNodes = 0;
this.overallProgress = 0;
this.estimatedCompletion = null;
// Performance metrics
this.metrics = {
totalDuration: 0,
averageNodeDuration: 0,
parallelizationEfficiency: 0,
totalTokensUsed: 0,
totalCost: 0,
errorRate: 0,
throughput: 0 // nodes per minute
};
// Real-time updates
this.updateInterval = null;
this.lastUpdateTime = new Date();
this.progressHistory = [];
}
/**
* Initialize node trackers
*/
initializeNodes(nodeConfigs) {
for (const nodeConfig of nodeConfigs) {
const tracker = new NodeProgressTracker(nodeConfig.id, nodeConfig.name);
this.nodeTrackers.set(nodeConfig.id, tracker);
}
}
/**
* Start workflow tracking
*/
start() {
this.startedAt = new Date();
this.status = 'running';
this.lastUpdateTime = new Date();
// Start real-time updates
this.updateInterval = setInterval(() => {
this.updateMetrics();
this.emitProgressUpdate();
}, 5000); // Update every 5 seconds
this.emit('workflow:started', {
workflowId: this.workflowId,
name: this.workflowName,
totalNodes: this.totalNodes,
startedAt: this.startedAt
});
}
/**
* Update node progress
*/
updateNodeProgress(nodeId, status, progress = 0, metadata = {}) {
const tracker = this.nodeTrackers.get(nodeId);
if (!tracker) {
console.warn(`Node tracker not found: ${nodeId}`);
return;
}
const previousStatus = tracker.status;
tracker.updateProgress(status, progress, metadata);
// Update counters when status changes
if (previousStatus !== status) {
this.updateCounters(previousStatus, status);
}
// Update overall progress
this.calculateOverallProgress();
this.updateMetrics();
// Emit node update
this.emit('node:updated', {
workflowId: this.workflowId,
nodeId,
status,
progress,
summary: tracker.getSummary()
});
// Check if workflow is complete
if (this.isWorkflowComplete()) {
this.complete();
}
}
/**
* Update node counters
*/
updateCounters(oldStatus, newStatus) {
// Decrement old status
if (oldStatus === 'running') this.runningNodes--;
// Increment new status
if (newStatus === 'running') this.runningNodes++;
else if (newStatus === 'completed') this.completedNodes++;
else if (newStatus === 'failed') this.failedNodes++;
else if (newStatus === 'skipped') this.skippedNodes++;
}
/**
* Calculate overall workflow progress
*/
calculateOverallProgress() {
if (this.totalNodes === 0) {
this.overallProgress = 0;
return;
}
let totalProgress = 0;
for (const tracker of this.nodeTrackers.values()) {
totalProgress += tracker.progress;
}
this.overallProgress = Math.round(totalProgress / this.totalNodes);
// Update estimated completion
this.calculateEstimatedCompletion();
}
/**
* Calculate estimated workflow completion time
*/
calculateEstimatedCompletion() {
if (!this.startedAt || this.overallProgress === 0) {
this.estimatedCompletion = null;
return;
}
const elapsed = new Date() - this.startedAt;
const progressRate = this.overallProgress / elapsed; // progress per ms
const remainingProgress = 100 - this.overallProgress;
const estimatedRemaining = remainingProgress / progressRate;
this.estimatedCompletion = new Date(Date.now() + estimatedRemaining);
}
/**
* Update performance metrics
*/
updateMetrics() {
const now = new Date();
const elapsed = now - this.startedAt;
// Calculate averages
const completedTrackers = Array.from(this.nodeTrackers.values())
.filter(t => t.completedAt);
if (completedTrackers.length > 0) {
const totalDuration = completedTrackers.reduce((sum, t) => sum + t.duration, 0);
this.metrics.averageNodeDuration = totalDuration / completedTrackers.length;
}
// Calculate throughput
this.metrics.throughput = this.completedNodes / (elapsed / 60000); // nodes per minute
// Calculate parallelization efficiency
const theoreticalSequentialTime = this.metrics.averageNodeDuration * this.totalNodes;
this.metrics.parallelizationEfficiency = theoreticalSequentialTime > 0
? (theoreticalSequentialTime / elapsed) * 100
: 0;
// Aggregate costs and tokens
this.metrics.totalTokensUsed = Array.from(this.nodeTrackers.values())
.reduce((sum, t) => sum + t.metrics.tokensUsed, 0);
this.metrics.totalCost = Array.from(this.nodeTrackers.values())
.reduce((sum, t) => sum + t.metrics.cost, 0);
// Calculate error rate
const totalErrors = Array.from(this.nodeTrackers.values())
.reduce((sum, t) => sum + t.metrics.errors.length, 0);
this.metrics.errorRate = (totalErrors / this.totalNodes) * 100;
// Store progress snapshot
this.progressHistory.push({
timestamp: now,
overallProgress: this.overallProgress,
runningNodes: this.runningNodes,
completedNodes: this.completedNodes,
failedNodes: this.failedNodes
});
// Keep only last 100 snapshots
if (this.progressHistory.length > 100) {
this.progressHistory = this.progressHistory.slice(-100);
}
}
/**
* Check if workflow is complete
*/
isWorkflowComplete() {
return (this.completedNodes + this.failedNodes + this.skippedNodes) >= this.totalNodes;
}
/**
* Mark workflow as complete
*/
complete(finalStatus = null) {
if (this.status !== 'running') return;
this.completedAt = new Date();
this.metrics.totalDuration = this.completedAt - this.startedAt;
// Determine final status
if (finalStatus) {
this.status = finalStatus;
} else if (this.failedNodes > 0) {
this.status = 'failed';
} else if (this.completedNodes === this.totalNodes) {
this.status = 'completed';
} else {
this.status = 'partial';
}
this.overallProgress = 100;
// Stop real-time updates
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
// Final metrics update
this.updateMetrics();
this.emit('workflow:completed', {
workflowId: this.workflowId,
status: this.status,
duration: this.metrics.totalDuration,
metrics: { ...this.metrics },
summary: this.getSummary()
});
}
/**
* Cancel workflow tracking
*/
cancel() {
this.status = 'cancelled';
this.complete('cancelled');
}
/**
* Emit progress update
*/
emitProgressUpdate() {
this.emit('progress:update', {
workflowId: this.workflowId,
overallProgress: this.overallProgress,
status: this.status,
nodes: {
total: this.totalNodes,
completed: this.completedNodes,
failed: this.failedNodes,
running: this.runningNodes,
pending: this.totalNodes - this.completedNodes - this.failedNodes - this.skippedNodes - this.runningNodes
},
estimatedCompletion: this.estimatedCompletion,
metrics: { ...this.metrics }
});
}
/**
* Get detailed progress summary
*/
getSummary() {
return {
workflowId: this.workflowId,
name: this.workflowName,
status: this.status,
overallProgress: this.overallProgress,
startedAt: this.startedAt,
completedAt: this.completedAt,
estimatedCompletion: this.estimatedCompletion,
nodes: {
total: this.totalNodes,
completed: this.completedNodes,
failed: this.failedNodes,
skipped: this.skippedNodes,
running: this.runningNodes,
pending: this.totalNodes - this.completedNodes - this.failedNodes - this.skippedNodes - this.runningNodes
},
metrics: { ...this.metrics },
nodeDetails: Array.from(this.nodeTrackers.values()).map(t => t.getSummary()),
progressHistory: [...this.progressHistory]
};
}
/**
* Get real-time progress for display
*/
getProgressDisplay() {
return {
workflowId: this.workflowId,
name: this.workflowName,
progress: this.overallProgress,
status: this.status,
nodesCompleted: this.completedNodes,
totalNodes: this.totalNodes,
runningNodes: this.runningNodes,
failedNodes: this.failedNodes,
estimatedCompletion: this.estimatedCompletion,
duration: this.startedAt ? new Date() - this.startedAt : 0,
throughput: this.metrics.throughput,
cost: this.metrics.totalCost
};
}
/**
* Get progress chart data
*/
getChartData() {
return {
progressOverTime: this.progressHistory.map(h => ({
timestamp: h.timestamp,
progress: h.overallProgress
})),
nodeStatusDistribution: {
completed: this.completedNodes,
failed: this.failedNodes,
running: this.runningNodes,
pending: this.totalNodes - this.completedNodes - this.failedNodes - this.skippedNodes - this.runningNodes,
skipped: this.skippedNodes
},
performanceMetrics: {
throughput: this.metrics.throughput,
parallelizationEfficiency: this.metrics.parallelizationEfficiency,
errorRate: this.metrics.errorRate,
averageNodeDuration: this.metrics.averageNodeDuration
}
};
}
/**
* Export progress data
*/
exportData() {
return {
workflow: this.getSummary(),
chartData: this.getChartData(),
exportedAt: new Date()
};
}
/**
* Cleanup resources
*/
cleanup() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
this.removeAllListeners();
this.nodeTrackers.clear();
this.progressHistory = [];
}
}
/**
* Progress tracker manager for multiple workflows
*/
class ProgressTrackerManager extends EventEmitter {
constructor() {
super();
this.trackers = new Map();
this.globalMetrics = {
totalWorkflows: 0,
activeWorkflows: 0,
completedWorkflows: 0,
failedWorkflows: 0,
totalNodes: 0,
totalCost: 0,
averageCompletionTime: 0
};
}
/**
* Create workflow tracker
*/
createTracker(workflowId, workflowName, totalNodes) {
if (this.trackers.has(workflowId)) {
throw new Error(`Tracker already exists: ${workflowId}`);
}
const tracker = new WorkflowProgressTracker(workflowId, workflowName, totalNodes);
// Forward events
tracker.on('workflow:started', (data) => this.emit('workflow:started', data));
tracker.on('workflow:completed', (data) => {
this.emit('workflow:completed', data);
this.updateGlobalMetrics();
});
tracker.on('node:updated', (data) => this.emit('node:updated', data));
tracker.on('progress:update', (data) => this.emit('progress:update', data));
this.trackers.set(workflowId, tracker);
this.updateGlobalMetrics();
return tracker;
}
/**
* Get workflow tracker
*/
getTracker(workflowId) {
return this.trackers.get(workflowId);
}
/**
* Remove workflow tracker
*/
removeTracker(workflowId) {
const tracker = this.trackers.get(workflowId);
if (tracker) {
tracker.cleanup();
this.trackers.delete(workflowId);
this.updateGlobalMetrics();
}
}
/**
* Update global metrics
*/
updateGlobalMetrics() {
const trackers = Array.from(this.trackers.values());
this.globalMetrics.totalWorkflows = trackers.length;
this.globalMetrics.activeWorkflows = trackers.filter(t => t.status === 'running').length;
this.globalMetrics.completedWorkflows = trackers.filter(t => t.status === 'completed').length;
this.globalMetrics.failedWorkflows = trackers.filter(t => t.status === 'failed').length;
this.globalMetrics.totalNodes = trackers.reduce((sum, t) => sum + t.totalNodes, 0);
this.globalMetrics.totalCost = trackers.reduce((sum, t) => sum + t.metrics.totalCost, 0);
const completedTrackers = trackers.filter(t => t.completedAt && t.startedAt);
if (completedTrackers.length > 0) {
const totalDuration = completedTrackers.reduce((sum, t) =>
sum + (t.completedAt - t.startedAt), 0);
this.globalMetrics.averageCompletionTime = totalDuration / completedTrackers.length;
}
}
/**
* Get all trackers
*/
getAllTrackers() {
return Array.from(this.trackers.values());
}
/**
* Get global progress overview
*/
getGlobalOverview() {
return {
metrics: { ...this.globalMetrics },
activeWorkflows: this.getAllTrackers()
.filter(t => t.status === 'running')
.map(t => t.getProgressDisplay())
};
}
/**
* Cleanup all trackers
*/
cleanup() {
for (const tracker of this.trackers.values()) {
tracker.cleanup();
}
this.trackers.clear();
this.removeAllListeners();
}
}
module.exports = {
WorkflowProgressTracker,
NodeProgressTracker,
ProgressTrackerManager
};