Joe Danziger e3ed4d7c14
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>
2025-08-12 22:37:07 +02:00

273 lines
7.6 KiB
JavaScript

import chalk from 'chalk';
import {
StreamingError,
STREAMING_ERROR_CODES
} from '../../../../src/utils/stream-parser.js';
import { TimeoutManager } from '../../../../src/utils/timeout-manager.js';
import { getDebugFlag, getDefaultPriority } from '../../config-manager.js';
// Import configuration classes
import { PrdParseConfig, LoggingConfig } from './parse-prd-config.js';
// Import helper functions
import {
readPrdContent,
loadExistingTasks,
validateFileOperations,
processTasks,
saveTasksToFile,
buildPrompts,
displayCliSummary,
displayNonStreamingCliOutput
} from './parse-prd-helpers.js';
// Import handlers
import { handleStreamingService } from './parse-prd-streaming.js';
import { handleNonStreamingService } from './parse-prd-non-streaming.js';
// ============================================================================
// MAIN PARSING FUNCTIONS (Simplified after refactoring)
// ============================================================================
/**
* Shared parsing logic for both streaming and non-streaming
* @param {PrdParseConfig} config - Configuration object
* @param {Function} serviceHandler - Handler function for AI service
* @param {boolean} isStreaming - Whether this is streaming mode
* @returns {Promise<Object>} Result object with success status and telemetry
*/
async function parsePRDCore(config, serviceHandler, isStreaming) {
const logger = new LoggingConfig(config.mcpLog, config.reportProgress);
logger.report(
`Parsing PRD file: ${config.prdPath}, Force: ${config.force}, Append: ${config.append}, Research: ${config.research}`,
'debug'
);
try {
// Load existing tasks
const { existingTasks, nextId } = loadExistingTasks(
config.tasksPath,
config.targetTag
);
// Validate operations
validateFileOperations({
existingTasks,
targetTag: config.targetTag,
append: config.append,
force: config.force,
isMCP: config.isMCP,
logger
});
// Read PRD content and build prompts
const prdContent = readPrdContent(config.prdPath);
const prompts = await buildPrompts(config, prdContent, nextId);
// Call the appropriate service handler
const serviceResult = await serviceHandler(
config,
prompts,
config.numTasks
);
// Process tasks
const defaultPriority = getDefaultPriority(config.projectRoot) || 'medium';
const processedNewTasks = processTasks(
serviceResult.parsedTasks,
nextId,
existingTasks,
defaultPriority
);
// Combine with existing if appending
const finalTasks = config.append
? [...existingTasks, ...processedNewTasks]
: processedNewTasks;
// Save to file
saveTasksToFile(config.tasksPath, finalTasks, config.targetTag, logger);
// Handle completion reporting
await handleCompletionReporting(
config,
serviceResult,
processedNewTasks,
finalTasks,
nextId,
isStreaming
);
return {
success: true,
tasksPath: config.tasksPath,
telemetryData: serviceResult.aiServiceResponse?.telemetryData,
tagInfo: serviceResult.aiServiceResponse?.tagInfo
};
} catch (error) {
logger.report(`Error parsing PRD: ${error.message}`, 'error');
if (!config.isMCP) {
console.error(chalk.red(`Error: ${error.message}`));
if (getDebugFlag(config.projectRoot)) {
console.error(error);
}
}
throw error;
}
}
/**
* Handle completion reporting for both CLI and MCP
* @param {PrdParseConfig} config - Configuration object
* @param {Object} serviceResult - Result from service handler
* @param {Array} processedNewTasks - New tasks that were processed
* @param {Array} finalTasks - All tasks after processing
* @param {number} nextId - Next available task ID
* @param {boolean} isStreaming - Whether this was streaming mode
*/
async function handleCompletionReporting(
config,
serviceResult,
processedNewTasks,
finalTasks,
nextId,
isStreaming
) {
const { aiServiceResponse, estimatedInputTokens, estimatedOutputTokens } =
serviceResult;
// MCP progress reporting
if (config.reportProgress) {
const hasValidTelemetry =
aiServiceResponse?.telemetryData &&
(aiServiceResponse.telemetryData.inputTokens > 0 ||
aiServiceResponse.telemetryData.outputTokens > 0);
let completionMessage;
if (hasValidTelemetry) {
const cost = aiServiceResponse.telemetryData.totalCost || 0;
const currency = aiServiceResponse.telemetryData.currency || 'USD';
completionMessage = `✅ Task Generation Completed | Tokens (I/O): ${aiServiceResponse.telemetryData.inputTokens}/${aiServiceResponse.telemetryData.outputTokens} | Cost: ${currency === 'USD' ? '$' : currency}${cost.toFixed(4)}`;
} else {
const outputTokens = isStreaming ? estimatedOutputTokens : 'unknown';
completionMessage = `✅ Task Generation Completed | ~Tokens (I/O): ${estimatedInputTokens}/${outputTokens} | Cost: ~$0.00`;
}
await config.reportProgress({
progress: config.numTasks,
total: config.numTasks,
message: completionMessage
});
}
// CLI output
if (config.outputFormat === 'text' && !config.isMCP) {
if (isStreaming && serviceResult.summary) {
await displayCliSummary({
processedTasks: processedNewTasks,
nextId,
summary: serviceResult.summary,
prdPath: config.prdPath,
tasksPath: config.tasksPath,
usedFallback: serviceResult.usedFallback,
aiServiceResponse
});
} else if (!isStreaming) {
displayNonStreamingCliOutput({
processedTasks: processedNewTasks,
research: config.research,
finalTasks,
tasksPath: config.tasksPath,
aiServiceResponse
});
}
}
}
/**
* Parse PRD with streaming progress reporting
*/
async function parsePRDWithStreaming(
prdPath,
tasksPath,
numTasks,
options = {}
) {
const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options);
return parsePRDCore(config, handleStreamingService, true);
}
/**
* Parse PRD without streaming (fallback)
*/
async function parsePRDWithoutStreaming(
prdPath,
tasksPath,
numTasks,
options = {}
) {
const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options);
return parsePRDCore(config, handleNonStreamingService, false);
}
/**
* Main entry point - decides between streaming and non-streaming
*/
async function parsePRD(prdPath, tasksPath, numTasks, options = {}) {
const config = new PrdParseConfig(prdPath, tasksPath, numTasks, options);
if (config.useStreaming) {
try {
return await parsePRDWithStreaming(prdPath, tasksPath, numTasks, options);
} catch (streamingError) {
// Check if this is a streaming-specific error (including timeout)
const isStreamingError =
streamingError instanceof StreamingError ||
streamingError.code === STREAMING_ERROR_CODES.NOT_ASYNC_ITERABLE ||
streamingError.code ===
STREAMING_ERROR_CODES.STREAM_PROCESSING_FAILED ||
streamingError.code === STREAMING_ERROR_CODES.STREAM_NOT_ITERABLE ||
TimeoutManager.isTimeoutError(streamingError);
if (isStreamingError) {
const logger = new LoggingConfig(config.mcpLog, config.reportProgress);
// Show fallback message
if (config.outputFormat === 'text' && !config.isMCP) {
console.log(
chalk.yellow(
`⚠️ Streaming operation ${streamingError.message.includes('timed out') ? 'timed out' : 'failed'}. Falling back to non-streaming mode...`
)
);
} else {
logger.report(
`Streaming failed (${streamingError.message}), falling back to non-streaming mode...`,
'warn'
);
}
// Fallback to non-streaming
return await parsePRDWithoutStreaming(
prdPath,
tasksPath,
numTasks,
options
);
} else {
throw streamingError;
}
}
} else {
return await parsePRDWithoutStreaming(
prdPath,
tasksPath,
numTasks,
options
);
}
}
export default parsePRD;