feat: Implement DeepWiki generator and CLI integration

- Added `deepwiki_generator.py` for generating documentation from source code.
- Integrated symbol extraction and markdown generation for supported file types.
- Implemented database migration for legacy timestamp formats in DeepWikiStore.
- Enhanced debug logging for better traceability during conversation and store operations.
- Updated dependencies in `PKG-INFO` and `requires.txt` for compatibility.
- Added new tests for the DeepWiki generator and storage functionalities.
- Refactored existing code for improved readability and maintainability.
This commit is contained in:
catlog22
2026-03-07 17:05:49 +08:00
parent 75d5f7f230
commit ece4afcac8
17 changed files with 816 additions and 25221 deletions

View File

@@ -201,7 +201,7 @@ export function run(argv: string[]): void {
.option('--status <status>', 'Filter by status')
.option('--category <category>', 'Execution category: user, internal, insight', 'user')
.option('--resume [id]', 'Resume previous session (empty=last, or execution ID, or comma-separated IDs for merge)')
.option('--id <id>', 'Custom execution ID (e.g., IMPL-001-step1)')
.option('--id <id>', 'Execution ID (recommended, auto-generated if omitted)')
.option('--no-native', 'Force prompt concatenation instead of native resume')
.option('--cache [items]', 'Cache: comma-separated @patterns and text content')
.option('--inject-mode <mode>', 'Inject mode: none, full, progressive (default: codex=full, others=none)')
@@ -226,7 +226,10 @@ export function run(argv: string[]): void {
.option('--output-type <type>', 'Output type: stdout, stderr, both', 'both')
.option('--turn <n>', 'Turn number for cache (default: latest)')
.option('--raw', 'Raw output only (no formatting)')
.option('--final', 'Output final result only with usage hint')
.option('--final', 'Output final result only (legacy, now default)')
.option('--verbose', 'Show full metadata in output view')
.option('--timeout <seconds>', 'Timeout for watch command')
.option('--all', 'Show all executions in show command')
.option('--to-file <path>', 'Save output to file')
.action((subcommand, args, options) => cliCommand(subcommand, args, options));

View File

@@ -10,6 +10,7 @@ import type { CliOutputUnit } from '../tools/cli-output-converter.js';
import { SmartContentFormatter } from '../tools/cli-output-converter.js';
import {
cliExecutorTool,
generateExecutionId,
getCliToolsStatus,
getExecutionHistory,
getExecutionHistoryAsync,
@@ -181,6 +182,7 @@ interface OutputViewOptions {
turn?: string;
raw?: boolean;
final?: boolean; // Only output final result with usage hint
verbose?: boolean; // Show full metadata (original default behavior)
project?: string; // Optional project path for lookup
}
@@ -432,56 +434,57 @@ async function outputAction(conversationId: string | undefined, options: OutputV
return;
}
if (options.final) {
// Final result only with usage hint
// Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout
const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content;
if (outputContent) {
console.log(outputContent);
}
if (options.verbose) {
// Verbose: full metadata + output (original default behavior)
console.log(chalk.bold.cyan('Execution Output\n'));
console.log(` ${chalk.gray('ID:')} ${result.conversationId}`);
console.log(` ${chalk.gray('Turn:')} ${result.turnNumber}`);
console.log(` ${chalk.gray('Cached:')} ${result.cached ? chalk.green('Yes') : chalk.yellow('No')}`);
console.log(` ${chalk.gray('Status:')} ${result.status}`);
console.log(` ${chalk.gray('Time:')} ${result.timestamp}`);
console.log(` ${chalk.gray('Project:')} ${chalk.cyan(projectPath)}`);
console.log();
console.log(chalk.gray('─'.repeat(60)));
console.log(chalk.dim(`Usage: ccw cli output ${conversationId} [options]`));
console.log(chalk.dim(' --raw Raw output (no formatting)'));
console.log(chalk.dim(' --offset <n> Start from byte offset'));
console.log(chalk.dim(' --limit <n> Limit output bytes'));
console.log(chalk.dim(' --project <p> Specify project path explicitly'));
console.log(chalk.dim(` --resume ccw cli -p "..." --resume ${conversationId}`));
if (result.stdout) {
console.log(` ${chalk.gray('Stdout:')} (${result.stdout.totalBytes} bytes, offset ${result.stdout.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stdout.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stdout.hasMore) {
console.log(chalk.yellow(` ... ${result.stdout.totalBytes - result.stdout.offset - result.stdout.content.length} more bytes available`));
console.log(chalk.gray(` Use --offset ${result.stdout.offset + result.stdout.content.length} to continue`));
}
console.log();
}
if (result.stderr && result.stderr.content) {
console.log(` ${chalk.gray('Stderr:')} (${result.stderr.totalBytes} bytes, offset ${result.stderr.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stderr.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stderr.hasMore) {
console.log(chalk.yellow(` ... ${result.stderr.totalBytes - result.stderr.offset - result.stderr.content.length} more bytes available`));
}
console.log();
}
return;
}
// Formatted output
console.log(chalk.bold.cyan('Execution Output\n'));
console.log(` ${chalk.gray('ID:')} ${result.conversationId}`);
console.log(` ${chalk.gray('Turn:')} ${result.turnNumber}`);
console.log(` ${chalk.gray('Cached:')} ${result.cached ? chalk.green('Yes') : chalk.yellow('No')}`);
console.log(` ${chalk.gray('Status:')} ${result.status}`);
console.log(` ${chalk.gray('Time:')} ${result.timestamp}`);
console.log(` ${chalk.gray('Project:')} ${chalk.cyan(projectPath)}`);
// Default: final result only (equivalent to --final)
// Prefer finalOutput (agent_message only) > parsedOutput (filtered) > raw stdout
const outputContent = result.finalOutput?.content || result.parsedOutput?.content || result.stdout?.content;
if (outputContent) {
console.log(outputContent);
}
console.log();
if (result.stdout) {
console.log(` ${chalk.gray('Stdout:')} (${result.stdout.totalBytes} bytes, offset ${result.stdout.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stdout.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stdout.hasMore) {
console.log(chalk.yellow(` ... ${result.stdout.totalBytes - result.stdout.offset - result.stdout.content.length} more bytes available`));
console.log(chalk.gray(` Use --offset ${result.stdout.offset + result.stdout.content.length} to continue`));
}
console.log();
}
if (result.stderr && result.stderr.content) {
console.log(` ${chalk.gray('Stderr:')} (${result.stderr.totalBytes} bytes, offset ${result.stderr.offset})`);
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(result.stderr.content);
console.log(chalk.gray(' ' + '-'.repeat(60)));
if (result.stderr.hasMore) {
console.log(chalk.yellow(` ... ${result.stderr.totalBytes - result.stderr.offset - result.stderr.content.length} more bytes available`));
}
console.log();
}
console.log(chalk.gray('\u2500'.repeat(60)));
console.log(chalk.dim(`Usage: ccw cli output ${conversationId} [options]`));
console.log(chalk.dim(' --verbose Show full metadata'));
console.log(chalk.dim(' --raw Raw output (no formatting)'));
console.log(chalk.dim(' --offset <n> Start from byte offset'));
console.log(chalk.dim(' --limit <n> Limit output bytes'));
console.log(chalk.dim(' --project <p> Specify project path explicitly'));
console.log(chalk.dim(` --resume ccw cli -p "..." --resume ${conversationId}`));
}
/**
@@ -922,8 +925,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
console.log();
}
// Generate execution ID for streaming (use custom ID or timestamp-based)
const executionId = id || `${Date.now()}-${tool}`;
// Generate execution ID for streaming (use custom ID or auto-generated readable ID)
const executionId = id || generateExecutionId(tool);
const startTime = Date.now();
const modelInfo = model ? ` @${model}` : '';
const spinnerBaseText = `Executing ${tool}${modelInfo} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...`;
@@ -989,9 +992,9 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
mode
});
if (process.env.DEBUG) {
console.error(`[CLI] Generated executionId: ${executionId}`);
}
// Always output execution ID to stderr for programmatic capture
// Callers can: ccw cli -p "..." 2>&1 | grep CCW_EXEC_ID
console.error(`[CCW_EXEC_ID=${executionId}]`);
// Buffer to accumulate output when both --stream and --to-file are specified
let streamBuffer = '';
@@ -1306,6 +1309,214 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
}
}
/**
* Show all executions — active (running) + recent completed
* Combines live dashboard state with SQLite history
*/
async function showAction(options: { all?: boolean }): Promise<void> {
console.log(chalk.bold.cyan('\n CLI Executions\n'));
// 1. Try to fetch active executions from dashboard
let activeExecs: Array<{
id: string; tool: string; mode: string; status: string;
prompt: string; startTime: number; isComplete?: boolean;
}> = [];
try {
const data = await new Promise<string>((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: Number(DASHBOARD_PORT),
path: '/api/cli/active',
method: 'GET',
timeout: 2000,
agent: false,
headers: { 'Connection': 'close' }
}, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve(body));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
const parsed = JSON.parse(data);
activeExecs = Array.isArray(parsed) ? parsed : (parsed.executions || []);
} catch {
// Dashboard not available — show only history
}
// 2. Get recent history from SQLite
const historyLimit = options.all ? 100 : 20;
const history = await getExecutionHistoryAsync(process.cwd(), { limit: historyLimit, recursive: true });
// 3. Build unified list: active first, then history (de-duped)
const seenIds = new Set<string>();
const rows: Array<{
id: string; tool: string; mode: string; status: string;
prompt: string; time: string; duration: string;
}> = [];
// Active executions (running)
for (const exec of activeExecs) {
if (exec.status === 'running') {
seenIds.add(exec.id);
const elapsed = Math.floor((Date.now() - exec.startTime) / 1000);
rows.push({
id: exec.id,
tool: exec.tool,
mode: exec.mode,
status: 'running',
prompt: (exec.prompt || '').replace(/\n/g, ' ').substring(0, 50),
time: `${elapsed}s ago`,
duration: `${elapsed}s...`,
});
}
}
// History executions
for (const exec of history.executions) {
if (seenIds.has(exec.id)) continue;
seenIds.add(exec.id);
const duration = exec.duration_ms >= 1000
? `${(exec.duration_ms / 1000).toFixed(1)}s`
: `${exec.duration_ms}ms`;
const timeAgo = getTimeAgo(new Date(exec.updated_at || exec.timestamp));
rows.push({
id: exec.id,
tool: exec.tool,
mode: exec.mode || 'analysis',
status: exec.status,
prompt: exec.prompt_preview.replace(/\n/g, ' ').substring(0, 50),
time: timeAgo,
duration,
});
}
if (rows.length === 0) {
console.log(chalk.gray(' No executions found.\n'));
return;
}
// 4. Render table
console.log(chalk.gray(' Status Tool Mode Duration Time ID'));
console.log(chalk.gray(' ' + '\u2500'.repeat(80)));
for (const row of rows) {
const statusIcon = row.status === 'running' ? chalk.blue('\u25CF') :
row.status === 'success' || row.status === 'completed' ? chalk.green('\u25CF') :
row.status === 'timeout' ? chalk.yellow('\u25CF') : chalk.red('\u25CF');
console.log(` ${statusIcon} ${chalk.bold.white(row.tool.padEnd(8))} ${chalk.gray(row.mode.padEnd(9))} ${chalk.gray(row.duration.padEnd(9))} ${chalk.gray(row.time.padEnd(11))} ${chalk.dim(row.id)}`);
if (row.prompt) {
console.log(chalk.gray(` ${row.prompt}${row.prompt.length >= 50 ? '...' : ''}`));
}
}
console.log();
console.log(chalk.gray(' ' + '\u2500'.repeat(80)));
console.log(chalk.dim(' Output: ccw cli output <id>'));
console.log(chalk.dim(' Watch: ccw cli watch <id>'));
console.log(chalk.dim(' Detail: ccw cli detail <id>'));
console.log();
}
/**
* Watch a running execution — stream output to stderr until completion
* Exits with code 0 (success), 1 (error), or 2 (timeout)
*/
async function watchAction(watchId: string | undefined, options: { timeout?: string }): Promise<void> {
if (!watchId) {
console.error(chalk.red('Error: Execution ID is required'));
console.error(chalk.gray('Usage: ccw cli watch <id> [--timeout 120]'));
process.exit(1);
}
const timeoutMs = options.timeout ? parseInt(options.timeout, 10) * 1000 : 0;
const startTime = Date.now();
process.stderr.write(chalk.cyan(`Watching execution: ${watchId}\n`));
// Track output position for incremental display
let lastOutputLen = 0;
const poll = async (): Promise<number> => {
// Check timeout
if (timeoutMs > 0 && (Date.now() - startTime) > timeoutMs) {
process.stderr.write(chalk.yellow('\nWatch timed out.\n'));
return 2;
}
try {
// Fetch active execution state from dashboard
const data = await new Promise<string>((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: Number(DASHBOARD_PORT),
path: '/api/cli/active',
method: 'GET',
timeout: 3000,
agent: false,
headers: { 'Connection': 'close' }
}, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve(body));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
const parsed = JSON.parse(data);
const executions = Array.isArray(parsed) ? parsed : (parsed.executions || []);
const exec = executions.find((e: { id: string }) => e.id === watchId);
if (exec) {
// Show incremental output
const fullOutput = exec.output || '';
if (fullOutput.length > lastOutputLen) {
process.stderr.write(fullOutput.slice(lastOutputLen));
lastOutputLen = fullOutput.length;
}
if (exec.status === 'running') {
// Still running — wait and poll again
await new Promise(r => setTimeout(r, 1000));
return poll();
}
// Completed
process.stderr.write(chalk.green(`\nExecution ${exec.status === 'completed' || exec.status === 'success' ? 'completed' : 'failed'}.\n`));
return (exec.status === 'completed' || exec.status === 'success') ? 0 : 1;
}
} catch {
// Dashboard not available
}
// Not found in active — check SQLite history
const store = getHistoryStore(process.cwd());
const result = store.getCachedOutput(watchId);
if (result) {
process.stderr.write(chalk.gray(`\nExecution already completed (status: ${result.status}).\n`));
process.stderr.write(chalk.dim(`Use: ccw cli output ${watchId}\n`));
return result.status === 'success' ? 0 : 1;
}
// Not found anywhere — may still be starting, wait and retry a few times
if ((Date.now() - startTime) < 10000) {
await new Promise(r => setTimeout(r, 1000));
return poll();
}
process.stderr.write(chalk.red(`\nExecution not found: ${watchId}\n`));
return 1;
};
const exitCode = await poll();
process.exit(exitCode);
}
/**
* Show execution history
* @param {Object} options - CLI options
@@ -1455,6 +1666,14 @@ export async function cliCommand(
await historyAction(options as HistoryOptions);
break;
case 'show':
await showAction(options as unknown as { all?: boolean });
break;
case 'watch':
await watchAction(argsArray[0], options as unknown as { timeout?: string });
break;
case 'detail':
await detailAction(argsArray[0]);
break;
@@ -1506,11 +1725,13 @@ export async function cliCommand(
console.log(chalk.gray(' echo "prompt" | ccw cli --tool <tool> Execute from stdin (pipe)'));
console.log();
console.log(' Subcommands:');
console.log(chalk.gray(' show List all executions (active + recent)'));
console.log(chalk.gray(' watch <id> Stream execution output (stderr, exits on completion)'));
console.log(chalk.gray(' output <id> Get final execution result'));
console.log(chalk.gray(' status Check CLI tools availability'));
console.log(chalk.gray(' storage [cmd] Manage CCW storage (info/clean/config)'));
console.log(chalk.gray(' history Show execution history'));
console.log(chalk.gray(' detail <id> Show execution detail'));
console.log(chalk.gray(' output <id> Show execution output with pagination'));
console.log(chalk.gray(' detail <id> Show execution detail (legacy, use show/output)'));
console.log(chalk.gray(' test-parse [args] Debug CLI argument parsing'));
console.log();
console.log(' Options:');
@@ -1523,7 +1744,7 @@ export async function cliCommand(
console.log(chalk.gray(' --effort <level> Effort level for claude (low, medium, high)'));
console.log(chalk.gray(' --cd <path> Working directory'));
console.log(chalk.gray(' --includeDirs <dirs> Additional directories'));
// --timeout removed - controlled by external caller (bash timeout)
console.log(chalk.gray(' --id <id> Execution ID (recommended, auto-generated if omitted)'));
console.log(chalk.gray(' --resume [id] Resume previous session'));
console.log(chalk.gray(' --cache <items> Cache: comma-separated @patterns and text'));
console.log(chalk.gray(' --inject-mode <m> Inject mode: none, full, progressive'));

View File

@@ -18,6 +18,7 @@ import {
deleteExecutionAsync,
batchDeleteExecutionsAsync,
executeCliTool,
generateExecutionId,
getNativeSessionContent,
getFormattedNativeConversation,
getEnrichedConversation,
@@ -940,7 +941,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
}
}
const executionId = `${Date.now()}-${tool}`;
const executionId = generateExecutionId(tool);
// Store active execution for state recovery
// Check map size limit before creating new execution

View File

@@ -11,13 +11,22 @@
import type { RouteContext } from './types.js';
import { getDeepWikiService } from '../../services/deepwiki-service.js';
import { handleDeepWikiRoutes as handleDeepWikiPostRoutes } from '../../services/deepwiki-service.js';
/**
* Handle DeepWiki routes
* @returns true if route was handled, false otherwise
*/
export async function handleDeepWikiRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, res } = ctx;
const { pathname, url, res, req } = ctx;
// POST endpoints implemented in the DeepWiki service module.
if (
(pathname === '/api/deepwiki/symbols-for-paths' || pathname === '/api/deepwiki/stale-files') &&
req.method === 'POST'
) {
return handleDeepWikiPostRoutes({ pathname, req, res });
}
// GET /api/deepwiki/files - List all documented files
if (pathname === '/api/deepwiki/files') {

View File

@@ -17,6 +17,7 @@ import { getLaunchConfig } from './cli-launch-registry.js';
import { assembleInstruction, type InstructionType } from './cli-instruction-assembler.js';
import { loadEndpointSettings } from '../../config/cli-settings-manager.js';
import { getToolConfig } from '../../tools/claude-cli-tools.js';
import { generateExecutionId } from '../../tools/cli-executor.js';
export interface CliSession {
sessionKey: string;
@@ -532,7 +533,7 @@ export class CliSessionManager {
const executionId = resumeKey
? `${resumeKey}-${Date.now()}`
: `exec-${Date.now()}-${randomBytes(3).toString('hex')}`;
: generateExecutionId(options.tool);
let command: string;

View File

@@ -35,22 +35,31 @@ import {
saveConversation
} from './cli-executor-state.js';
// Track all running child processes for cleanup on interruption (multi-process support)
const runningChildProcesses = new Set<ChildProcess>();
// Debug logging for parallel execution testing
// Debug logging for history save investigation (Iteration 4)
const DEBUG_SESSION_ID = 'DBG-parallel-ccw-cli-test-2026-03-07';
const DEBUG_LOG_PATH = path.join(process.cwd(), '.workflow', '.debug', DEBUG_SESSION_ID, 'debug.log');
const DEBUG_LOG_PATH = path.join(process.cwd(), '.workflow', '.debug', DEBUG_SESSION_ID, 'debug-save.log');
// Ensure debug log directory exists
try {
const debugDir = path.dirname(DEBUG_LOG_PATH);
if (!fs.existsSync(debugDir)) {
fs.mkdirSync(debugDir, { recursive: true });
}
} catch (err) {
// Ignore directory creation errors
}
function writeDebugLog(event: string, data: Record<string, any>): void {
try {
const logEntry = JSON.stringify({ event, ...data, timestamp: new Date().toISOString() }) + '\n';
fs.appendFileSync(DEBUG_LOG_PATH, logEntry, 'utf8');
} catch (err) {
// Silently ignore logging errors to avoid disrupting execution
// Silently ignore logging errors
}
}
// Track all running child processes for cleanup on interruption (multi-process support)
const runningChildProcesses = new Set<ChildProcess>();
/**
* Kill all running CLI child processes
@@ -58,16 +67,14 @@ function writeDebugLog(event: string, data: Record<string, any>): void {
*/
export function killAllCliProcesses(): boolean {
if (runningChildProcesses.size === 0) return false;
writeDebugLog('KILL_ALL_START', { initial_set_size: runningChildProcesses.size });
const processesToKill = Array.from(runningChildProcesses);
debugLog('KILL', `Killing ${processesToKill.length} child process(es)`, { pids: processesToKill.map(p => p.pid) });
writeDebugLog('KILL_ALL_COPY', { pids_to_kill: processesToKill.map(p => p.pid) });
// 1. SIGTERM for graceful shutdown
for (const child of processesToKill) {
if (!child.killed) {
try { child.kill('SIGTERM'); } catch (e: any) { writeDebugLog('KILL_SIGTERM_ERROR', { pid: child.pid, error: e.message }); }
try { child.kill('SIGTERM'); } catch { /* ignore */ }
}
}
@@ -75,13 +82,12 @@ export function killAllCliProcesses(): boolean {
const killTimeout = setTimeout(() => {
for (const child of processesToKill) {
if (!child.killed) {
try { child.kill('SIGKILL'); } catch (e: any) { writeDebugLog('KILL_SIGKILL_ERROR', { pid: child.pid, error: e.message }); }
try { child.kill('SIGKILL'); } catch { /* ignore */ }
}
}
}, 2000);
killTimeout.unref();
writeDebugLog('KILL_ALL_CLEAR', { set_size_before: runningChildProcesses.size, pids_in_set: Array.from(runningChildProcesses).map(p => p.pid) });
runningChildProcesses.clear();
return true;
}
@@ -257,7 +263,6 @@ async function executeClaudeWithSettings(params: ClaudeWithSettingsParams): Prom
// Track child process for cleanup (multi-process support)
runningChildProcesses.add(child);
writeDebugLog('PROCESS_ADD', { pid: child.pid, set_size_after: runningChildProcesses.size, function: 'executeClaudeWithSettings' });
let stdout = '';
let stderr = '';
@@ -297,7 +302,6 @@ async function executeClaudeWithSettings(params: ClaudeWithSettingsParams): Prom
child.on('close', (code) => {
runningChildProcesses.delete(child);
writeDebugLog('PROCESS_DELETE', { pid: child.pid, exit_code: code, set_size_after: runningChildProcesses.size, function: 'executeClaudeWithSettings', handler: 'close' });
const endTime = Date.now();
const duration = endTime - startTime;
@@ -338,10 +342,8 @@ async function executeClaudeWithSettings(params: ClaudeWithSettingsParams): Prom
// Save to history
try {
writeDebugLog('SAVE_CONVERSATION_START', { conversationId: conversation.id, pid: child.pid, function: 'executeClaudeWithSettings' });
saveConversation(workingDir, conversation);
} catch (err) {
writeDebugLog('SAVE_CONVERSATION_ERROR', { conversationId: conversation.id, pid: child.pid, error: (err as Error).message, stack: (err as Error).stack, function: 'executeClaudeWithSettings' });
console.error('[CLI Executor] Failed to save CLI封装 history:', (err as Error).message);
}
@@ -356,7 +358,6 @@ async function executeClaudeWithSettings(params: ClaudeWithSettingsParams): Prom
child.on('error', (error) => {
runningChildProcesses.delete(child);
writeDebugLog('PROCESS_DELETE', { pid: child.pid, set_size_after: runningChildProcesses.size, function: 'executeClaudeWithSettings', handler: 'error' });
reject(new Error(`Failed to spawn claude: ${error.message}`));
});
});
@@ -391,6 +392,30 @@ type BuiltinCliTool = typeof BUILTIN_CLI_TOOLS[number];
*/
export type TransactionId = string;
/**
* Generate a readable execution ID for CLI executions
* Format: {prefix}-{HHmmss}-{rand4} → e.g. gem-143022-x7k2
* @param tool - CLI tool name (gemini, qwen, codex, claude, opencode, litellm, etc.)
* @returns Short, human-readable execution ID
*/
export function generateExecutionId(tool: string): string {
const prefixMap: Record<string, string> = {
gemini: 'gem',
qwen: 'qwn',
codex: 'cdx',
claude: 'cld',
opencode: 'opc',
litellm: 'llm',
};
const prefix = prefixMap[tool] || tool.slice(0, 3);
const now = new Date();
const time = [now.getHours(), now.getMinutes(), now.getSeconds()]
.map(n => String(n).padStart(2, '0'))
.join('');
const rand = Math.random().toString(36).slice(2, 6);
return `${prefix}-${time}-${rand}`;
}
/**
* Generate a unique transaction ID for the current execution
* @param conversationId - CCW conversation ID
@@ -526,7 +551,7 @@ async function executeCliTool(
const duration = endTime - startTime;
const execution: ExecutionRecord = {
id: customId || `${Date.now()}-litellm`,
id: customId || generateExecutionId('litellm'),
timestamp: new Date(startTime).toISOString(),
tool: 'litellm',
model: result.model,
@@ -676,7 +701,7 @@ async function executeCliTool(
const duration = endTime - startTime;
const execution: ExecutionRecord = {
id: customId || `${Date.now()}-litellm`,
id: customId || generateExecutionId('litellm'),
timestamp: new Date(startTime).toISOString(),
tool: toolName,
model: result.model, // Use effective model from result (reflects any override)
@@ -789,11 +814,11 @@ async function executeCliTool(
existingConversation = loadConversation(workingDir, conversationId);
} else {
// No previous conversation, create new
conversationId = `${Date.now()}-${tool}`;
conversationId = generateExecutionId(tool);
}
} else {
// New conversation with auto-generated ID
conversationId = `${Date.now()}-${tool}`;
conversationId = generateExecutionId(tool);
}
// Generate transaction ID for concurrent session disambiguation
@@ -1019,7 +1044,6 @@ async function executeCliTool(
// Track child process for cleanup on interruption (multi-process support)
runningChildProcesses.add(child);
writeDebugLog('PROCESS_ADD', { pid: child.pid, set_size_after: runningChildProcesses.size, function: 'executeCliTool', tool });
debugLog('SPAWN', `Process spawned`, { pid: child.pid });
@@ -1071,7 +1095,6 @@ async function executeCliTool(
child.on('close', async (code) => {
// Remove from running processes
runningChildProcesses.delete(child);
writeDebugLog('PROCESS_DELETE', { pid: child.pid, exit_code: code, set_size_after: runningChildProcesses.size, function: 'executeCliTool', handler: 'close', tool });
// Flush remaining buffer from parser
const remainingUnits = parser.flush();
@@ -1200,11 +1223,9 @@ async function executeCliTool(
// Save all source conversations
try {
for (const conv of savedConversations) {
writeDebugLog('SAVE_CONVERSATION_START', { conversationId: conv.id, pid: child.pid, function: 'executeCliTool', context: 'merge-loop', tool });
saveConversation(workingDir, conv);
}
} catch (err) {
writeDebugLog('SAVE_CONVERSATION_ERROR', { pid: child.pid, error: (err as Error).message, stack: (err as Error).stack, function: 'executeCliTool', context: 'merge-loop', tool });
console.error('[CLI Executor] Failed to save merged histories:', (err as Error).message);
}
} else if (isMerge && mergeResult && customId) {
@@ -1244,10 +1265,8 @@ async function executeCliTool(
};
// Save merged conversation
try {
writeDebugLog('SAVE_CONVERSATION_START', { conversationId: conversation.id, pid: child.pid, function: 'executeCliTool', context: 'merge-with-id', tool });
saveConversation(workingDir, conversation);
} catch (err) {
writeDebugLog('SAVE_CONVERSATION_ERROR', { conversationId: conversation.id, pid: child.pid, error: (err as Error).message, stack: (err as Error).stack, function: 'executeCliTool', context: 'merge-with-id', tool });
console.error('[CLI Executor] Failed to save merged conversation:', (err as Error).message);
}
} else {
@@ -1277,10 +1296,11 @@ async function executeCliTool(
};
// Try to save conversation to history
try {
writeDebugLog('SAVE_CONVERSATION_START', { conversationId: conversation.id, pid: child.pid, function: 'executeCliTool', context: 'normal', tool });
writeDebugLog('BEFORE_SAVE_CONV', { conversationId: conversation.id, workingDir, tool });
saveConversation(workingDir, conversation);
writeDebugLog('AFTER_SAVE_CONV', { conversationId: conversation.id, workingDir, tool });
} catch (err) {
writeDebugLog('SAVE_CONVERSATION_ERROR', { conversationId: conversation.id, pid: child.pid, error: (err as Error).message, stack: (err as Error).stack, function: 'executeCliTool', context: 'normal', tool });
writeDebugLog('SAVE_CONV_OUTER_ERROR', { conversationId: conversation.id, workingDir, tool, error: (err as Error).message, stack: (err as Error).stack });
// Non-fatal: continue even if history save fails
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
}
@@ -1341,7 +1361,6 @@ async function executeCliTool(
child.on('error', (error) => {
// Remove from running processes
runningChildProcesses.delete(child);
writeDebugLog('PROCESS_DELETE', { pid: child.pid, set_size_after: runningChildProcesses.size, function: 'executeCliTool', handler: 'error', tool });
errorLog('SPAWN', `Failed to spawn process`, error, {
tool,

View File

@@ -6,6 +6,31 @@
import type { HistoryIndexEntry } from './cli-history-store.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
import type { CliOutputUnit } from './cli-output-converter.js';
import * as fs from 'fs';
import * as path from 'path';
// Debug logging for history save investigation (Iteration 4)
const DEBUG_SESSION_ID = 'DBG-parallel-ccw-cli-test-2026-03-07';
const DEBUG_LOG_PATH = path.join(process.cwd(), '.workflow', '.debug', DEBUG_SESSION_ID, 'debug-save.log');
// Ensure debug log directory exists
try {
const debugDir = path.dirname(DEBUG_LOG_PATH);
if (!fs.existsSync(debugDir)) {
fs.mkdirSync(debugDir, { recursive: true });
}
} catch (err) {
// Ignore directory creation errors
}
function writeDebugLog(event: string, data: Record<string, any>): void {
try {
const logEntry = JSON.stringify({ event, ...data, timestamp: new Date().toISOString() }) + '\n';
fs.appendFileSync(DEBUG_LOG_PATH, logEntry, 'utf8');
} catch (err) {
// Silently ignore logging errors
}
}
// Lazy-loaded SQLite store module
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
@@ -14,8 +39,10 @@ let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
* Get or initialize SQLite store (async)
*/
export async function getSqliteStore(baseDir: string) {
writeDebugLog('GET_STORE', { baseDir, baseDirType: typeof baseDir, moduleInitialized: sqliteStoreModule !== null });
if (!sqliteStoreModule) {
sqliteStoreModule = await import('./cli-history-store.js');
writeDebugLog('MODULE_LOADED', { baseDir });
}
return sqliteStoreModule.getHistoryStore(baseDir);
}
@@ -136,15 +163,20 @@ async function saveConversationAsync(baseDir: string, conversation: Conversation
* @param baseDir - Project base directory (NOT historyDir)
*/
export function saveConversation(baseDir: string, conversation: ConversationRecord): void {
writeDebugLog('SAVE_CONV_START', { baseDir, conversationId: conversation.id, moduleInitialized: sqliteStoreModule !== null });
try {
const store = getSqliteStoreSync(baseDir);
writeDebugLog('SAVE_CONV_SYNC', { baseDir, conversationId: conversation.id });
// Fire and forget - don't block on async save in sync context
store.saveConversation(conversation).catch(err => {
writeDebugLog('SAVE_CONV_ERROR', { baseDir, conversationId: conversation.id, error: err.message, stack: err.stack });
console.error('[CLI Executor] Failed to save conversation:', err.message);
});
} catch {
} catch (err) {
writeDebugLog('SAVE_CONV_FALLBACK_ASYNC', { baseDir, conversationId: conversation.id, error: (err as Error).message });
// If sync not available, queue for async save
saveConversationAsync(baseDir, conversation).catch(err => {
writeDebugLog('SAVE_CONV_ASYNC_ERROR', { baseDir, conversationId: conversation.id, error: err.message, stack: err.stack });
console.error('[CLI Executor] Failed to save conversation:', err.message);
});
}

View File

@@ -4,13 +4,36 @@
*/
import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync, appendFileSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
import { getDiscoverer, getNativeSessions } from './native-session-discovery.js';
import { StoragePaths, ensureStorageDir, getProjectId, getCCWHome } from '../config/storage-paths.js';
import type { CliOutputUnit } from './cli-output-converter.js';
// Debug logging for history save investigation (Iteration 4)
const DEBUG_SESSION_ID = 'DBG-parallel-ccw-cli-test-2026-03-07';
const DEBUG_LOG_PATH = join(process.cwd(), '.workflow', '.debug', DEBUG_SESSION_ID, 'debug-save.log');
// Ensure debug log directory exists
try {
const debugDir = dirname(DEBUG_LOG_PATH);
if (!existsSync(debugDir)) {
mkdirSync(debugDir, { recursive: true });
}
} catch (err) {
// Ignore directory creation errors
}
function writeDebugLog(event: string, data: Record<string, any>): void {
try {
const logEntry = JSON.stringify({ event, ...data, timestamp: new Date().toISOString() }) + '\n';
appendFileSync(DEBUG_LOG_PATH, logEntry, 'utf8');
} catch (err) {
// Silently ignore logging errors
}
}
// Types
export interface ConversationTurn {
turn: number;
@@ -110,22 +133,29 @@ export class CliHistoryStore {
private projectPath: string;
constructor(baseDir: string) {
writeDebugLog('STORE_CONSTRUCT_START', { baseDir });
this.projectPath = baseDir;
// Use centralized storage path
const paths = StoragePaths.project(baseDir);
const historyDir = paths.cliHistory;
writeDebugLog('STORAGE_PATHS', { baseDir, historyDir, historyDb: paths.historyDb });
ensureStorageDir(historyDir);
this.dbPath = paths.historyDb;
writeDebugLog('DB_INSTANCE_CREATE', { dbPath: this.dbPath });
this.db = new Database(this.dbPath);
writeDebugLog('DB_INSTANCE_CREATED', { dbPath: this.dbPath });
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('busy_timeout = 10000'); // Wait up to 10 seconds for locks (increased for write-heavy scenarios)
this.db.pragma('wal_autocheckpoint = 1000'); // Optimize WAL checkpointing
writeDebugLog('INIT_SCHEMA_START', { dbPath: this.dbPath });
this.initSchema();
writeDebugLog('INIT_SCHEMA_COMPLETE', { dbPath: this.dbPath });
this.migrateFromJson(historyDir);
writeDebugLog('STORE_CONSTRUCT_COMPLETE', { baseDir, dbPath: this.dbPath });
}
/**
@@ -936,11 +966,7 @@ export class CliHistoryStore {
const stmt = this.db.prepare(`
INSERT INTO native_session_mapping (ccw_id, tool, native_session_id, native_session_path, project_hash, transaction_id, created_at)
VALUES (@ccw_id, @tool, @native_session_id, @native_session_path, @project_hash, @transaction_id, @created_at)
ON CONFLICT(ccw_id) DO UPDATE SET
native_session_id = @native_session_id,
native_session_path = @native_session_path,
project_hash = @project_hash,
transaction_id = @transaction_id
ON CONFLICT(tool, native_session_id) DO NOTHING
`);
await this.withRetry(() => stmt.run({