feat: CLI & MCP progress tracking for parse-prd command (#1048)
* initial cutover * update log to debug * update tracker to pass units * update test to match new base tracker format * add streamTextService mocks * remove unused imports * Ensure the CLI waits for async main() completion * refactor to reduce code duplication * update comment * reuse function * ensure targetTag is defined in streaming mode * avoid throwing inside process.exit spy * check for null * remove reference to generate * fix formatting * fix textStream assignment * ensure no division by 0 * fix jest chalk mocks * refactor for maintainability * Improve bar chart calculation logic for consistent visual representation * use custom streaming error types; fix mocks * Update streamText extraction in parse-prd.js to match actual service response * remove check - doesn't belong here * update mocks * remove streaming test that wasn't really doing anything * add comment * make parsing logic more DRY * fix formatting * Fix textStream extraction to match actual service response * fix mock * Add a cleanup method to ensure proper resource disposal and prevent memory leaks * debounce progress updates to reduce UI flicker during rapid updates * Implement timeout protection for streaming operations (60-second timeout) with automatic fallback to non-streaming mode. * clear timeout properly * Add a maximum buffer size limit (1MB) to prevent unbounded memory growth with very large streaming responses. * fix formatting * remove duplicate mock * better docs * fix formatting * sanitize the dynamic property name * Fix incorrect remaining progress calculation * Use onError callback instead of console.warn * Remove unused chalk import * Add missing custom validator in fallback parsing configuration * add custom validator parameter in fallback parsing * chore: fix package-lock.json * chore: large code refactor * chore: increase timeout from 1 minute to 3 minutes * fix: refactor and fix streaming * Merge remote-tracking branch 'origin/next' into joedanz/parse-prd-progress * fix: cleanup and fix unit tests * chore: fix unit tests * chore: fix format * chore: run format * chore: fix weird CI unit test error * chore: fix format --------- Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
This commit is contained in:
298
src/progress/base-progress-tracker.js
Normal file
298
src/progress/base-progress-tracker.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import { newMultiBar } from './cli-progress-factory.js';
|
||||
|
||||
/**
|
||||
* Base class for progress trackers, handling common logic for time, tokens, estimation, and multibar management.
|
||||
*/
|
||||
export class BaseProgressTracker {
|
||||
constructor(options = {}) {
|
||||
this.numUnits = options.numUnits || 1;
|
||||
this.unitName = options.unitName || 'unit'; // e.g., 'task', 'subtask'
|
||||
this.startTime = null;
|
||||
this.completedUnits = 0;
|
||||
this.tokensIn = 0;
|
||||
this.tokensOut = 0;
|
||||
this.isEstimate = true; // For token display
|
||||
|
||||
// Time estimation properties
|
||||
this.bestAvgTimePerUnit = null;
|
||||
this.lastEstimateTime = null;
|
||||
this.lastEstimateSeconds = 0;
|
||||
|
||||
// UI components
|
||||
this.multibar = null;
|
||||
this.timeTokensBar = null;
|
||||
this.progressBar = null;
|
||||
this._timerInterval = null;
|
||||
|
||||
// State flags
|
||||
this.isStarted = false;
|
||||
this.isFinished = false;
|
||||
|
||||
// Allow subclasses to define custom properties
|
||||
this._initializeCustomProperties(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method for subclasses to initialize custom properties.
|
||||
* @protected
|
||||
*/
|
||||
_initializeCustomProperties(options) {
|
||||
// Subclasses can override this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pluralized form of the unit name for safe property keys.
|
||||
* @returns {string} Pluralized unit name
|
||||
*/
|
||||
get unitNamePlural() {
|
||||
return `${this.unitName}s`;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isStarted || this.isFinished) return;
|
||||
|
||||
this.isStarted = true;
|
||||
this.startTime = Date.now();
|
||||
|
||||
this.multibar = newMultiBar();
|
||||
|
||||
// Create time/tokens bar using subclass-provided format
|
||||
this.timeTokensBar = this.multibar.create(
|
||||
1,
|
||||
0,
|
||||
{},
|
||||
{
|
||||
format: this._getTimeTokensBarFormat(),
|
||||
barsize: 1,
|
||||
hideCursor: true,
|
||||
clearOnComplete: false
|
||||
}
|
||||
);
|
||||
|
||||
// Create main progress bar using subclass-provided format
|
||||
this.progressBar = this.multibar.create(
|
||||
this.numUnits,
|
||||
0,
|
||||
{},
|
||||
{
|
||||
format: this._getProgressBarFormat(),
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591'
|
||||
}
|
||||
);
|
||||
|
||||
this._updateTimeTokensBar();
|
||||
this.progressBar.update(0, { [this.unitNamePlural]: `0/${this.numUnits}` });
|
||||
|
||||
// Start timer
|
||||
this._timerInterval = setInterval(() => this._updateTimeTokensBar(), 1000);
|
||||
|
||||
// Allow subclasses to add custom bars or setup
|
||||
this._setupCustomUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method for subclasses to add custom UI elements after start.
|
||||
* @protected
|
||||
*/
|
||||
_setupCustomUI() {
|
||||
// Subclasses can override this
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method to get the format for the time/tokens bar.
|
||||
* @protected
|
||||
* @returns {string} Format string for the time/tokens bar.
|
||||
*/
|
||||
_getTimeTokensBarFormat() {
|
||||
return `{clock} {elapsed} | Tokens (I/O): {in}/{out} | Est: {remaining}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method to get the format for the main progress bar.
|
||||
* @protected
|
||||
* @returns {string} Format string for the progress bar.
|
||||
*/
|
||||
_getProgressBarFormat() {
|
||||
return `${this.unitName.charAt(0).toUpperCase() + this.unitName.slice(1)}s {${this.unitNamePlural}} |{bar}| {percentage}%`;
|
||||
}
|
||||
|
||||
updateTokens(tokensIn, tokensOut, isEstimate = false) {
|
||||
this.tokensIn = tokensIn || 0;
|
||||
this.tokensOut = tokensOut || 0;
|
||||
this.isEstimate = isEstimate;
|
||||
this._updateTimeTokensBar();
|
||||
}
|
||||
|
||||
_updateTimeTokensBar() {
|
||||
if (!this.timeTokensBar || this.isFinished) return;
|
||||
|
||||
const elapsed = this._formatElapsedTime();
|
||||
const remaining = this._estimateRemainingTime();
|
||||
const tokensLabel = this.isEstimate ? '~ Tokens (I/O)' : 'Tokens (I/O)';
|
||||
|
||||
this.timeTokensBar.update(1, {
|
||||
clock: '⏱️',
|
||||
elapsed,
|
||||
in: this.tokensIn,
|
||||
out: this.tokensOut,
|
||||
remaining,
|
||||
tokensLabel,
|
||||
// Subclasses can add more payload here via override
|
||||
...this._getCustomTimeTokensPayload()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method for subclasses to provide custom payload for time/tokens bar.
|
||||
* @protected
|
||||
* @returns {Object} Custom payload object.
|
||||
*/
|
||||
_getCustomTimeTokensPayload() {
|
||||
return {};
|
||||
}
|
||||
|
||||
_formatElapsedTime() {
|
||||
if (!this.startTime) return '0m 00s';
|
||||
const seconds = Math.floor((Date.now() - this.startTime) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
_estimateRemainingTime() {
|
||||
const progress = this._getProgressFraction();
|
||||
if (progress >= 1) return '~0s';
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = (now - this.startTime) / 1000;
|
||||
|
||||
if (progress === 0) return '~calculating...';
|
||||
|
||||
const avgTimePerUnit = elapsed / progress;
|
||||
|
||||
if (
|
||||
this.bestAvgTimePerUnit === null ||
|
||||
avgTimePerUnit < this.bestAvgTimePerUnit
|
||||
) {
|
||||
this.bestAvgTimePerUnit = avgTimePerUnit;
|
||||
}
|
||||
|
||||
const remainingUnits = this.numUnits * (1 - progress);
|
||||
let estimatedSeconds = Math.ceil(remainingUnits * this.bestAvgTimePerUnit);
|
||||
|
||||
// Stabilization logic
|
||||
if (this.lastEstimateTime) {
|
||||
const elapsedSinceEstimate = Math.floor(
|
||||
(now - this.lastEstimateTime) / 1000
|
||||
);
|
||||
const countdownSeconds = Math.max(
|
||||
0,
|
||||
this.lastEstimateSeconds - elapsedSinceEstimate
|
||||
);
|
||||
if (countdownSeconds === 0) return '~0s';
|
||||
estimatedSeconds = Math.min(estimatedSeconds, countdownSeconds);
|
||||
}
|
||||
|
||||
this.lastEstimateTime = now;
|
||||
this.lastEstimateSeconds = estimatedSeconds;
|
||||
|
||||
return `~${this._formatDuration(estimatedSeconds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method for subclasses to calculate current progress fraction (0-1).
|
||||
* Defaults to simple completedUnits / numUnits.
|
||||
* @protected
|
||||
* @returns {number} Progress fraction (can be fractional for subtasks).
|
||||
*/
|
||||
_getProgressFraction() {
|
||||
return this.completedUnits / this.numUnits;
|
||||
}
|
||||
|
||||
_formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return remainingSeconds > 0
|
||||
? `${minutes}m ${remainingSeconds}s`
|
||||
: `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
getElapsedTime() {
|
||||
return this.startTime ? Date.now() - this.startTime : 0;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.isFinished) return;
|
||||
|
||||
this.isFinished = true;
|
||||
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
|
||||
if (this.multibar) {
|
||||
this._updateTimeTokensBar();
|
||||
this.multibar.stop();
|
||||
}
|
||||
|
||||
// Ensure cleanup is called to prevent memory leaks
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
getSummary() {
|
||||
return {
|
||||
completedUnits: this.completedUnits,
|
||||
elapsedTime: this.getElapsedTime()
|
||||
// Subclasses should extend this
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method to ensure proper resource disposal and prevent memory leaks.
|
||||
* Should be called when the progress tracker is no longer needed.
|
||||
*/
|
||||
cleanup() {
|
||||
// Stop any active timers
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
|
||||
// Stop and clear multibar
|
||||
if (this.multibar) {
|
||||
try {
|
||||
this.multibar.stop();
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.multibar = null;
|
||||
}
|
||||
|
||||
// Clear progress bar references
|
||||
this.timeTokensBar = null;
|
||||
this.progressBar = null;
|
||||
|
||||
// Reset state
|
||||
this.isStarted = false;
|
||||
this.isFinished = true;
|
||||
|
||||
// Allow subclasses to perform custom cleanup
|
||||
this._performCustomCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected method for subclasses to perform custom cleanup.
|
||||
* @protected
|
||||
*/
|
||||
_performCustomCleanup() {
|
||||
// Subclasses can override this
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user