feat(storage): implement storage manager for centralized management and cleanup

- Added a new Storage Manager component to handle storage statistics, project cleanup, and configuration for CCW centralized storage.
- Introduced functions to calculate directory sizes, get project storage stats, and clean specific or all storage.
- Enhanced SQLiteStore with a public API for executing queries securely.
- Updated tests to utilize the new execute_query method and validate storage management functionalities.
- Improved performance by implementing connection pooling with idle timeout management in SQLiteStore.
- Added new fields (token_count, symbol_type) to the symbols table and adjusted related insertions.
- Enhanced error handling and logging for storage operations.
This commit is contained in:
catlog22
2025-12-15 17:39:38 +08:00
parent ee0886fc48
commit 97640a517a
36 changed files with 2108 additions and 841 deletions

View File

@@ -165,6 +165,13 @@ export function run(argv: string[]): void {
.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('--no-native', 'Force prompt concatenation instead of native resume')
// Storage options
.option('--project <path>', 'Project path for storage operations')
.option('--force', 'Confirm destructive operations')
.option('--cli-history', 'Target CLI history storage')
.option('--memory', 'Target memory storage')
.option('--cache', 'Target cache storage')
.option('--config', 'Target config storage')
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
// Memory command

View File

@@ -9,9 +9,21 @@ import {
cliExecutorTool,
getCliToolsStatus,
getExecutionHistory,
getExecutionHistoryAsync,
getExecutionDetail,
getConversationDetail
} from '../tools/cli-executor.js';
import {
getStorageStats,
getStorageConfig,
cleanProjectStorage,
cleanAllStorage,
formatBytes,
formatTimeAgo,
resolveProjectId,
projectExists,
getStorageLocationInstructions
} from '../tools/storage-manager.js';
// Dashboard notification settings
const DASHBOARD_PORT = process.env.CCW_PORT || 3456;
@@ -64,6 +76,199 @@ interface HistoryOptions {
status?: string;
}
interface StorageOptions {
all?: boolean;
project?: string;
cliHistory?: boolean;
memory?: boolean;
cache?: boolean;
config?: boolean;
force?: boolean;
}
/**
* Show storage information and management options
*/
async function storageAction(subAction: string | undefined, options: StorageOptions): Promise<void> {
switch (subAction) {
case 'info':
case undefined:
await showStorageInfo();
break;
case 'clean':
await cleanStorage(options);
break;
case 'config':
showStorageConfig();
break;
default:
showStorageHelp();
}
}
/**
* Show storage information
*/
async function showStorageInfo(): Promise<void> {
console.log(chalk.bold.cyan('\n CCW Storage Information\n'));
const config = getStorageConfig();
const stats = getStorageStats();
// Configuration
console.log(chalk.bold.white(' Location:'));
console.log(` ${chalk.cyan(stats.rootPath)}`);
if (config.isCustom) {
console.log(chalk.gray(` (Custom: CCW_DATA_DIR=${config.envVar})`));
}
console.log();
// Summary
console.log(chalk.bold.white(' Summary:'));
console.log(` Total Size: ${chalk.yellow(formatBytes(stats.totalSize))}`);
console.log(` Projects: ${chalk.yellow(stats.projectCount.toString())}`);
console.log(` Global DB: ${stats.globalDb.exists ? chalk.green(formatBytes(stats.globalDb.size)) : chalk.gray('Not created')}`);
console.log();
// Projects breakdown
if (stats.projects.length > 0) {
console.log(chalk.bold.white(' Projects:'));
console.log(chalk.gray(' ID Size History Last Used'));
console.log(chalk.gray(' ─────────────────────────────────────────────────────'));
for (const project of stats.projects) {
const historyInfo = project.cliHistory.recordCount !== undefined
? `${project.cliHistory.recordCount} records`
: (project.cliHistory.exists ? 'Yes' : '-');
console.log(
` ${chalk.dim(project.projectId)} ` +
`${formatBytes(project.totalSize).padStart(8)} ` +
`${historyInfo.padStart(10)} ` +
`${chalk.gray(formatTimeAgo(project.lastModified))}`
);
}
console.log();
}
// Usage tips
console.log(chalk.gray(' Commands:'));
console.log(chalk.gray(' ccw cli storage clean Clean all storage'));
console.log(chalk.gray(' ccw cli storage clean --project <path> Clean specific project'));
console.log(chalk.gray(' ccw cli storage config Show location config'));
console.log();
}
/**
* Clean storage
*/
async function cleanStorage(options: StorageOptions): Promise<void> {
const { all, project, force, cliHistory, memory, cache, config } = options;
// Determine what to clean
const cleanTypes = {
cliHistory: cliHistory || (!cliHistory && !memory && !cache && !config),
memory: memory || (!cliHistory && !memory && !cache && !config),
cache: cache || (!cliHistory && !memory && !cache && !config),
config: config || false, // Config requires explicit flag
all: !cliHistory && !memory && !cache && !config
};
if (project) {
// Clean specific project
const projectId = resolveProjectId(project);
if (!projectExists(projectId)) {
console.log(chalk.yellow(`\n No storage found for project: ${project}`));
console.log(chalk.gray(` (Project ID: ${projectId})\n`));
return;
}
if (!force) {
console.log(chalk.bold.yellow('\n Warning: This will delete storage for project:'));
console.log(` Path: ${project}`);
console.log(` ID: ${projectId}`);
console.log(chalk.gray('\n Use --force to confirm deletion.\n'));
return;
}
console.log(chalk.bold.cyan('\n Cleaning project storage...\n'));
const result = cleanProjectStorage(projectId, cleanTypes);
if (result.success) {
console.log(chalk.green(` ✓ Cleaned ${formatBytes(result.freedBytes)}`));
} else {
console.log(chalk.red(' ✗ Cleanup completed with errors:'));
for (const err of result.errors) {
console.log(chalk.red(` - ${err}`));
}
}
} else {
// Clean all storage
const stats = getStorageStats();
if (stats.projectCount === 0) {
console.log(chalk.yellow('\n No storage to clean.\n'));
return;
}
if (!force) {
console.log(chalk.bold.yellow('\n Warning: This will delete ALL CCW storage:'));
console.log(` Location: ${stats.rootPath}`);
console.log(` Projects: ${stats.projectCount}`);
console.log(` Size: ${formatBytes(stats.totalSize)}`);
console.log(chalk.gray('\n Use --force to confirm deletion.\n'));
return;
}
console.log(chalk.bold.cyan('\n Cleaning all storage...\n'));
const result = cleanAllStorage(cleanTypes);
if (result.success) {
console.log(chalk.green(` ✓ Cleaned ${result.projectsCleaned} projects, freed ${formatBytes(result.freedBytes)}`));
} else {
console.log(chalk.yellow(` ⚠ Cleaned ${result.projectsCleaned} projects with some errors:`));
for (const err of result.errors) {
console.log(chalk.red(` - ${err}`));
}
}
}
console.log();
}
/**
* Show storage configuration
*/
function showStorageConfig(): void {
console.log(getStorageLocationInstructions());
}
/**
* Show storage help
*/
function showStorageHelp(): void {
console.log(chalk.bold.cyan('\n CCW Storage Management\n'));
console.log(' Subcommands:');
console.log(chalk.gray(' info Show storage information (default)'));
console.log(chalk.gray(' clean Clean storage'));
console.log(chalk.gray(' config Show configuration instructions'));
console.log();
console.log(' Clean Options:');
console.log(chalk.gray(' --project <path> Clean specific project storage'));
console.log(chalk.gray(' --force Confirm deletion'));
console.log(chalk.gray(' --cli-history Clean only CLI history'));
console.log(chalk.gray(' --memory Clean only memory store'));
console.log(chalk.gray(' --cache Clean only cache'));
console.log(chalk.gray(' --config Clean config (requires explicit flag)'));
console.log();
console.log(' Examples:');
console.log(chalk.gray(' ccw cli storage # Show storage info'));
console.log(chalk.gray(' ccw cli storage clean --force # Clean all storage'));
console.log(chalk.gray(' ccw cli storage clean --project . --force # Clean current project'));
console.log(chalk.gray(' ccw cli storage config # Show config instructions'));
console.log();
}
/**
* Show CLI tool status
*/
@@ -231,7 +436,7 @@ async function historyAction(options: HistoryOptions): Promise<void> {
console.log(chalk.bold.cyan('\n CLI Execution History\n'));
const history = getExecutionHistory(process.cwd(), { limit: parseInt(limit, 10), tool, status });
const history = await getExecutionHistoryAsync(process.cwd(), { limit: parseInt(limit, 10), tool, status });
if (history.executions.length === 0) {
console.log(chalk.gray(' No executions found.\n'));
@@ -360,11 +565,16 @@ export async function cliCommand(
await detailAction(argsArray[0]);
break;
case 'storage':
await storageAction(argsArray[0], options as unknown as StorageOptions);
break;
default:
console.log(chalk.bold.cyan('\n CCW CLI Tool Executor\n'));
console.log(' Unified interface for Gemini, Qwen, and Codex CLI tools.\n');
console.log(' Subcommands:');
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(' exec <prompt> Execute a CLI tool'));
console.log(chalk.gray(' history Show execution history'));
console.log(chalk.gray(' detail <id> Show execution detail'));

View File

@@ -9,6 +9,7 @@ import { HistoryImporter } from '../core/history-importer.js';
import { notifyMemoryUpdate, notifyRefreshRequired } from '../tools/notifier.js';
import { join } from 'path';
import { existsSync, readdirSync } from 'fs';
import { StoragePaths } from '../config/storage-paths.js';
interface TrackOptions {
type?: string;
@@ -228,13 +229,13 @@ async function importAction(options: ImportOptions): Promise<void> {
try {
const projectPath = getProjectPath();
const memoryDir = join(projectPath, '.workflow', '.memory');
const dbPath = join(memoryDir, 'history.db');
const paths = StoragePaths.project(projectPath);
const dbPath = join(paths.memory, 'history.db');
// Ensure memory directory exists
const { mkdirSync } = await import('fs');
if (!existsSync(memoryDir)) {
mkdirSync(memoryDir, { recursive: true });
if (!existsSync(paths.memory)) {
mkdirSync(paths.memory, { recursive: true });
}
const importer = new HistoryImporter(dbPath);
@@ -569,17 +570,16 @@ async function pruneAction(options: PruneOptions): Promise<void> {
const cutoffStr = cutoffDate.toISOString();
const projectPath = getProjectPath();
const memoryDir = join(projectPath, '.workflow', '.memory');
const dbPath = join(memoryDir, 'memory.db');
const paths = StoragePaths.project(projectPath);
if (!existsSync(dbPath)) {
if (!existsSync(paths.memoryDb)) {
console.log(chalk.yellow(' No memory database found. Nothing to prune.\n'));
return;
}
// Use direct database access for pruning
const Database = require('better-sqlite3');
const db = new Database(dbPath);
const db = new Database(paths.memoryDb);
// Count records to prune
const accessLogsCount = db.prepare(`

View File

@@ -110,12 +110,35 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
};
}
/**
* Get storage paths for a project by its ID (hash)
* Use when iterating centralized storage without original project path
* @param projectId - 16-character project ID hash
* @returns Object with all project-specific paths
*/
export function getProjectPathsById(projectId: string): ProjectPaths {
const projectDir = join(CCW_HOME, 'projects', projectId);
return {
root: projectDir,
cliHistory: join(projectDir, 'cli-history'),
historyDb: join(projectDir, 'cli-history', 'history.db'),
memory: join(projectDir, 'memory'),
memoryDb: join(projectDir, 'memory', 'memory.db'),
cache: join(projectDir, 'cache'),
dashboardCache: join(projectDir, 'cache', 'dashboard-data.json'),
config: join(projectDir, 'config'),
cliConfig: join(projectDir, 'config', 'cli-config.json'),
};
}
/**
* Unified StoragePaths object combining global and project paths
*/
export const StoragePaths = {
global: GlobalPaths,
project: getProjectPaths,
projectById: getProjectPathsById,
};
/**

View File

@@ -29,8 +29,11 @@ export class CacheManager<T> {
* @param options - Cache configuration options
*/
constructor(cacheKey: string, options: CacheOptions = {}) {
if (!options.cacheDir) {
throw new Error('CacheManager requires cacheDir option. Use StoragePaths.project(path).cache');
}
this.ttl = options.ttl || 5 * 60 * 1000; // Default: 5 minutes
this.cacheDir = options.cacheDir || '.ccw-cache';
this.cacheDir = options.cacheDir;
this.cacheFile = join(this.cacheDir, `${cacheKey}.json`);
}

View File

@@ -10,6 +10,15 @@ import { join } from 'path';
import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay } from '../../utils/path-resolver.js';
import { scanSessions } from '../session-scanner.js';
import { aggregateData } from '../data-aggregator.js';
import {
getStorageStats,
getStorageConfig,
cleanProjectStorage,
cleanAllStorage,
resolveProjectId,
projectExists,
formatBytes
} from '../../tools/storage-manager.js';
export interface RouteContext {
pathname: string;
@@ -325,5 +334,94 @@ export async function handleSystemRoutes(ctx: RouteContext): Promise<boolean> {
return true;
}
// API: Get storage statistics
if (pathname === '/api/storage/stats') {
try {
const stats = getStorageStats();
const config = getStorageConfig();
// Format for dashboard display
const response = {
location: stats.rootPath,
isCustomLocation: config.isCustom,
totalSize: stats.totalSize,
totalSizeFormatted: formatBytes(stats.totalSize),
projectCount: stats.projectCount,
globalDb: stats.globalDb,
projects: stats.projects.map(p => ({
id: p.projectId,
totalSize: p.totalSize,
totalSizeFormatted: formatBytes(p.totalSize),
historyRecords: p.cliHistory.recordCount ?? 0,
hasCliHistory: p.cliHistory.exists,
hasMemory: p.memory.exists,
hasCache: p.cache.exists,
lastModified: p.lastModified?.toISOString() || null
}))
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to get storage stats', details: String(err) }));
}
return true;
}
// API: Clean storage
if (pathname === '/api/storage/clean' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { projectId, projectPath, all, types } = body as {
projectId?: string;
projectPath?: string;
all?: boolean;
types?: { cliHistory?: boolean; memory?: boolean; cache?: boolean; config?: boolean };
};
const cleanOptions = types || { all: true };
if (projectId) {
// Clean specific project by ID
if (!projectExists(projectId)) {
return { error: 'Project not found', status: 404 };
}
const result = cleanProjectStorage(projectId, cleanOptions);
return {
success: result.success,
freedBytes: result.freedBytes,
freedFormatted: formatBytes(result.freedBytes),
errors: result.errors
};
} else if (projectPath) {
// Clean specific project by path
const id = resolveProjectId(projectPath);
if (!projectExists(id)) {
return { error: 'No storage found for project', status: 404 };
}
const result = cleanProjectStorage(id, cleanOptions);
return {
success: result.success,
freedBytes: result.freedBytes,
freedFormatted: formatBytes(result.freedBytes),
errors: result.errors
};
} else if (all) {
// Clean all storage
const result = cleanAllStorage(cleanOptions);
return {
success: result.success,
projectsCleaned: result.projectsCleaned,
freedBytes: result.freedBytes,
freedFormatted: formatBytes(result.freedBytes),
errors: result.errors
};
} else {
return { error: 'Specify projectId, projectPath, or all=true', status: 400 };
}
});
return true;
}
return false;
}

View File

@@ -82,6 +82,7 @@ const MODULE_FILES = [
'components/hook-manager.js',
'components/cli-status.js',
'components/cli-history.js',
'components/storage-manager.js',
'components/_exp_helpers.js',
'components/tabs-other.js',
'components/tabs-context.js',
@@ -295,11 +296,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return;
}
// System routes (data, health, version, paths, shutdown, notify)
// System routes (data, health, version, paths, shutdown, notify, storage)
if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify') {
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname.startsWith('/api/storage/')) {
if (await handleSystemRoutes(routeContext)) return;
}

View File

@@ -0,0 +1,340 @@
// ==========================================
// STORAGE MANAGER COMPONENT
// ==========================================
// Manages CCW centralized storage (~/.ccw/)
// State
let storageData = null;
let storageLoading = false;
/**
* Initialize storage manager
*/
async function initStorageManager() {
await loadStorageStats();
}
/**
* Load storage statistics from API
*/
async function loadStorageStats() {
if (storageLoading) return;
storageLoading = true;
try {
const res = await fetch('/api/storage/stats');
if (!res.ok) throw new Error('Failed to load storage stats');
storageData = await res.json();
renderStorageCard();
} catch (err) {
console.error('Failed to load storage stats:', err);
renderStorageCardError(err.message);
} finally {
storageLoading = false;
}
}
/**
* Render storage card in the dashboard
*/
function renderStorageCard() {
const container = document.getElementById('storageCard');
if (!container || !storageData) return;
const { location, totalSizeFormatted, projectCount, projects } = storageData;
// Format relative time
const formatTimeAgo = (isoString) => {
if (!isoString) return 'Never';
const date = new Date(isoString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return diffMins + 'm ago';
if (diffHours < 24) return diffHours + 'h ago';
if (diffDays < 30) return diffDays + 'd ago';
return date.toLocaleDateString();
};
// Build project rows
let projectRows = '';
if (projects && projects.length > 0) {
projects.slice(0, 5).forEach(p => {
const historyBadge = p.historyRecords > 0
? '<span class="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">' + p.historyRecords + '</span>'
: '<span class="text-xs text-muted-foreground">-</span>';
projectRows += '\
<tr class="border-b border-border/50 hover:bg-muted/30">\
<td class="py-2 px-2 font-mono text-xs text-muted-foreground">' + escapeHtml(p.id.substring(0, 8)) + '...</td>\
<td class="py-2 px-2 text-sm text-right">' + escapeHtml(p.totalSizeFormatted) + '</td>\
<td class="py-2 px-2 text-center">' + historyBadge + '</td>\
<td class="py-2 px-2 text-xs text-muted-foreground text-right">' + formatTimeAgo(p.lastModified) + '</td>\
<td class="py-2 px-1 text-right">\
<button onclick="cleanProjectStorage(\'' + escapeHtml(p.id) + '\')" \
class="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors" \
title="Clean this project storage">\
<i data-lucide="trash-2" class="w-3 h-3"></i>\
</button>\
</td>\
</tr>\
';
});
if (projects.length > 5) {
projectRows += '\
<tr>\
<td colspan="5" class="py-2 px-2 text-xs text-muted-foreground text-center">\
... and ' + (projects.length - 5) + ' more projects\
</td>\
</tr>\
';
}
} else {
projectRows = '\
<tr>\
<td colspan="5" class="py-4 text-center text-muted-foreground text-sm">No storage data yet</td>\
</tr>\
';
}
container.innerHTML = '\
<div class="bg-card border border-border rounded-lg overflow-hidden">\
<div class="bg-muted/30 border-b border-border px-4 py-3 flex items-center justify-between">\
<div class="flex items-center gap-2">\
<i data-lucide="hard-drive" class="w-4 h-4 text-primary"></i>\
<span class="font-medium text-foreground">Storage Manager</span>\
<span class="text-xs px-2 py-0.5 bg-muted rounded-full text-muted-foreground">' + totalSizeFormatted + '</span>\
</div>\
<div class="flex items-center gap-2">\
<button onclick="loadStorageStats()" class="text-xs px-2 py-1 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors" title="Refresh">\
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>\
</button>\
<button onclick="showStorageConfig()" class="text-xs px-2 py-1 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors" title="Settings">\
<i data-lucide="settings" class="w-3.5 h-3.5"></i>\
</button>\
</div>\
</div>\
<div class="p-4">\
<div class="flex items-center gap-2 mb-3 text-xs text-muted-foreground">\
<i data-lucide="folder" class="w-3.5 h-3.5"></i>\
<span class="font-mono truncate" title="' + escapeHtml(location) + '">' + escapeHtml(location) + '</span>\
</div>\
<div class="grid grid-cols-3 gap-3 mb-4">\
<div class="bg-muted/30 rounded-lg p-3 text-center">\
<div class="text-lg font-semibold text-foreground">' + projectCount + '</div>\
<div class="text-xs text-muted-foreground">Projects</div>\
</div>\
<div class="bg-muted/30 rounded-lg p-3 text-center">\
<div class="text-lg font-semibold text-foreground">' + totalSizeFormatted + '</div>\
<div class="text-xs text-muted-foreground">Total Size</div>\
</div>\
<div class="bg-muted/30 rounded-lg p-3 text-center">\
<div class="text-lg font-semibold text-foreground">' + getTotalRecords() + '</div>\
<div class="text-xs text-muted-foreground">Records</div>\
</div>\
</div>\
<div class="border border-border rounded-lg overflow-hidden">\
<table class="w-full text-sm">\
<thead class="bg-muted/50">\
<tr class="text-xs text-muted-foreground">\
<th class="py-2 px-2 text-left font-medium">Project ID</th>\
<th class="py-2 px-2 text-right font-medium">Size</th>\
<th class="py-2 px-2 text-center font-medium">History</th>\
<th class="py-2 px-2 text-right font-medium">Last Used</th>\
<th class="py-2 px-1 w-8"></th>\
</tr>\
</thead>\
<tbody>\
' + projectRows + '\
</tbody>\
</table>\
</div>\
<div class="mt-4 flex justify-end gap-2">\
<button onclick="cleanAllStorageConfirm()" \
class="text-xs px-3 py-1.5 bg-destructive/10 text-destructive hover:bg-destructive/20 rounded transition-colors flex items-center gap-1.5">\
<i data-lucide="trash" class="w-3.5 h-3.5"></i>\
Clean All\
</button>\
</div>\
</div>\
</div>\
';
// Reinitialize Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
/**
* Get total records across all projects
*/
function getTotalRecords() {
if (!storageData || !storageData.projects) return 0;
return storageData.projects.reduce((sum, p) => sum + (p.historyRecords || 0), 0);
}
/**
* Render error state for storage card
*/
function renderStorageCardError(message) {
const container = document.getElementById('storageCard');
if (!container) return;
container.innerHTML = '\
<div class="bg-card border border-border rounded-lg overflow-hidden">\
<div class="bg-muted/30 border-b border-border px-4 py-3 flex items-center gap-2">\
<i data-lucide="hard-drive" class="w-4 h-4 text-primary"></i>\
<span class="font-medium text-foreground">Storage Manager</span>\
</div>\
<div class="p-4 text-center">\
<div class="text-destructive mb-2">\
<i data-lucide="alert-circle" class="w-8 h-8 mx-auto"></i>\
</div>\
<p class="text-sm text-muted-foreground mb-3">' + escapeHtml(message) + '</p>\
<button onclick="loadStorageStats()" class="text-xs px-3 py-1.5 bg-primary/10 text-primary hover:bg-primary/20 rounded transition-colors">\
Retry\
</button>\
</div>\
</div>\
';
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
/**
* Show storage configuration modal
*/
function showStorageConfig() {
const content = '\
# Storage Configuration\n\
\n\
## Current Location\n\
\n\
```\n\
' + (storageData?.location || '~/.ccw') + '\n\
```\n\
\n\
## Change Storage Location\n\
\n\
Set the `CCW_DATA_DIR` environment variable to change the storage location:\n\
\n\
### Windows (PowerShell)\n\
```powershell\n\
$env:CCW_DATA_DIR = "D:\\custom\\ccw-data"\n\
```\n\
\n\
### Windows (Command Prompt)\n\
```cmd\n\
set CCW_DATA_DIR=D:\\custom\\ccw-data\n\
```\n\
\n\
### Linux/macOS\n\
```bash\n\
export CCW_DATA_DIR="/custom/ccw-data"\n\
```\n\
\n\
### Permanent (add to shell profile)\n\
```bash\n\
echo \'export CCW_DATA_DIR="/custom/ccw-data"\' >> ~/.bashrc\n\
```\n\
\n\
> **Note:** Existing data will NOT be migrated automatically.\n\
> Manually copy the contents of the old directory to the new location.\n\
\n\
## CLI Commands\n\
\n\
```bash\n\
# Show storage info\n\
ccw cli storage\n\
\n\
# Clean all storage\n\
ccw cli storage clean --force\n\
\n\
# Clean specific project\n\
ccw cli storage clean --project . --force\n\
```\n\
';
openMarkdownModal('Storage Configuration', content, 'markdown');
}
/**
* Clean storage for a specific project
*/
async function cleanProjectStorage(projectId) {
const project = storageData?.projects?.find(p => p.id === projectId);
const sizeInfo = project ? ' (' + project.totalSizeFormatted + ')' : '';
if (!confirm('Delete storage for project ' + projectId.substring(0, 8) + '...' + sizeInfo + '?\n\nThis will remove CLI history, memory, and cache for this project.')) {
return;
}
try {
const res = await fetch('/api/storage/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId })
});
const result = await res.json();
if (result.success) {
addGlobalNotification('success', 'Storage Cleaned', 'Freed ' + result.freedFormatted, 'storage');
await loadStorageStats();
} else {
throw new Error(result.error || 'Failed to clean storage');
}
} catch (err) {
addGlobalNotification('error', 'Clean Failed', err.message, 'storage');
}
}
/**
* Confirm and clean all storage
*/
async function cleanAllStorageConfirm() {
const totalSize = storageData?.totalSizeFormatted || 'unknown';
const projectCount = storageData?.projectCount || 0;
if (!confirm('Delete ALL CCW storage?\n\nThis will remove:\n- ' + projectCount + ' projects\n- ' + totalSize + ' of data\n\nThis action cannot be undone!')) {
return;
}
// Double confirm for safety
if (!confirm('Are you SURE? This will delete all CLI history, memory stores, and caches.')) {
return;
}
try {
const res = await fetch('/api/storage/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true })
});
const result = await res.json();
if (result.success) {
addGlobalNotification('success', 'All Storage Cleaned', 'Cleaned ' + result.projectsCleaned + ' projects, freed ' + result.freedFormatted, 'storage');
await loadStorageStats();
} else {
throw new Error(result.error || 'Failed to clean storage');
}
} catch (err) {
addGlobalNotification('error', 'Clean Failed', err.message, 'storage');
}
}
/**
* Get storage data (for external use)
*/
function getStorageData() {
return storageData;
}

View File

@@ -297,6 +297,10 @@ async function renderCliManager() {
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Show storage card (only visible in CLI Manager view)
var storageCard = document.getElementById('storageCard');
if (storageCard) storageCard.style.display = '';
// Load data (including CodexLens status for tools section)
await Promise.all([
loadCliToolStatus(),
@@ -320,6 +324,11 @@ async function renderCliManager() {
renderCliSettingsSection();
renderCcwEndpointToolsSection();
// Initialize storage manager card
if (typeof initStorageManager === 'function') {
initStorageManager();
}
// Initialize Lucide icons
if (window.lucide) lucide.createIcons();
}

View File

@@ -6,6 +6,10 @@ function renderDashboard() {
// Show stats grid and search (may be hidden by MCP view)
showStatsAndSearch();
// Hide storage card (only shown in CLI Manager view)
const storageCard = document.getElementById('storageCard');
if (storageCard) storageCard.style.display = 'none';
updateStats();
updateBadges();
updateCarousel();

View File

@@ -556,6 +556,7 @@ async function renderMcpManager() {
</div>
</div>
` : ''}
`}
<!-- MCP Server Details Modal -->
<div id="mcpDetailsModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 hidden">

View File

@@ -529,6 +529,11 @@
</div>
</div>
<!-- Storage Manager Card (only visible in CLI Manager view) -->
<section id="storageCard" class="mb-6" style="display: none;">
<!-- Rendered by storage-manager.js -->
</section>
<!-- Main Content Container -->
<section class="main-content" id="mainContent">
<!-- Dynamic content: sessions grid or session detail page -->

View File

@@ -28,6 +28,7 @@ import {
disableTool as disableToolFromConfig,
getPrimaryModel
} from './cli-config-manager.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
// Lazy-loaded SQLite store module
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
@@ -401,36 +402,34 @@ function buildCommand(params: {
}
/**
* Ensure history directory exists
* Ensure history directory exists (uses centralized storage)
*/
function ensureHistoryDir(baseDir: string): string {
const historyDir = join(baseDir, '.workflow', '.cli-history');
if (!existsSync(historyDir)) {
mkdirSync(historyDir, { recursive: true });
}
return historyDir;
const paths = StoragePaths.project(baseDir);
ensureStorageDir(paths.cliHistory);
return paths.cliHistory;
}
/**
* Save conversation to SQLite
* @param baseDir - Project base directory (NOT historyDir)
*/
async function saveConversationAsync(historyDir: string, conversation: ConversationRecord): Promise<void> {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
async function saveConversationAsync(baseDir: string, conversation: ConversationRecord): Promise<void> {
const store = await getSqliteStore(baseDir);
store.saveConversation(conversation);
}
/**
* Sync wrapper for saveConversation (uses cached SQLite module)
* @param baseDir - Project base directory (NOT historyDir)
*/
function saveConversation(historyDir: string, conversation: ConversationRecord): void {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
function saveConversation(baseDir: string, conversation: ConversationRecord): void {
try {
const store = getSqliteStoreSync(baseDir);
store.saveConversation(conversation);
} catch {
// If sync not available, queue for async save
saveConversationAsync(historyDir, conversation).catch(err => {
saveConversationAsync(baseDir, conversation).catch(err => {
console.error('[CLI Executor] Failed to save conversation:', err.message);
});
}
@@ -438,18 +437,18 @@ function saveConversation(historyDir: string, conversation: ConversationRecord):
/**
* Load existing conversation by ID from SQLite
* @param baseDir - Project base directory (NOT historyDir)
*/
async function loadConversationAsync(historyDir: string, conversationId: string): Promise<ConversationRecord | null> {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
async function loadConversationAsync(baseDir: string, conversationId: string): Promise<ConversationRecord | null> {
const store = await getSqliteStore(baseDir);
return store.getConversation(conversationId);
}
/**
* Sync wrapper for loadConversation (uses cached SQLite module)
* @param baseDir - Project base directory (NOT historyDir)
*/
function loadConversation(historyDir: string, conversationId: string): ConversationRecord | null {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
function loadConversation(baseDir: string, conversationId: string): ConversationRecord | null {
try {
const store = getSqliteStoreSync(baseDir);
return store.getConversation(conversationId);
@@ -601,7 +600,7 @@ async function executeCliTool(
if (isMerge) {
// Merge scenario: multiple resume IDs
sourceConversations = resumeIds
.map(id => loadConversation(historyDir, id))
.map(id => loadConversation(workingDir, id))
.filter((c): c is ConversationRecord => c !== null);
if (sourceConversations.length === 0) {
@@ -613,7 +612,7 @@ async function executeCliTool(
if (customId) {
// Create new merged conversation with custom ID
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
existingConversation = loadConversation(workingDir, customId);
} else {
// Will append to ALL source conversations (handled in save logic)
// Use first source conversation ID as primary
@@ -623,22 +622,22 @@ async function executeCliTool(
} else if (customId && resumeId) {
// Fork: read context from resume ID, but create new conversation with custom ID
conversationId = customId;
contextConversation = loadConversation(historyDir, resumeId);
existingConversation = loadConversation(historyDir, customId);
contextConversation = loadConversation(workingDir, resumeId);
existingConversation = loadConversation(workingDir, customId);
} else if (customId) {
// Use custom ID - may be new or existing
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
existingConversation = loadConversation(workingDir, customId);
} else if (resumeId) {
// Resume single ID without new ID - append to existing conversation
conversationId = resumeId;
existingConversation = loadConversation(historyDir, resumeId);
existingConversation = loadConversation(workingDir, resumeId);
} else if (resume) {
// resume=true: get last conversation for this tool
const history = getExecutionHistory(workingDir, { limit: 1, tool });
if (history.executions.length > 0) {
conversationId = history.executions[0].id;
existingConversation = loadConversation(historyDir, conversationId);
existingConversation = loadConversation(workingDir, conversationId);
} else {
// No previous conversation, create new
conversationId = `${Date.now()}-${tool}`;
@@ -668,9 +667,9 @@ async function executeCliTool(
customId,
forcePromptConcat: noNative,
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
getConversation: (ccwId) => loadConversation(historyDir, ccwId),
getConversation: (ccwId) => loadConversation(workingDir, ccwId),
getConversationTool: (ccwId) => {
const conv = loadConversation(historyDir, ccwId);
const conv = loadConversation(workingDir, ccwId);
return conv?.tool || null;
}
});
@@ -1078,40 +1077,37 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
}
/**
* Find all CLI history directories in a directory tree (max depth 3)
* Find all project directories with CLI history in centralized storage
* Returns list of project base directories (NOT history directories)
*/
function findCliHistoryDirs(baseDir: string, maxDepth: number = 3): string[] {
const historyDirs: string[] = [];
const ignoreDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', 'venv', '.venv']);
function findProjectsWithHistory(): string[] {
const projectDirs: string[] = [];
const projectsRoot = join(StoragePaths.global.root(), 'projects');
function scanDir(dir: string, depth: number) {
if (depth > maxDepth) return;
// Check if this directory has CLI history (SQLite database)
const historyDir = join(dir, '.workflow', '.cli-history');
if (existsSync(join(historyDir, 'history.db'))) {
historyDirs.push(historyDir);
}
// Scan subdirectories
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.') && !ignoreDirs.has(entry.name)) {
scanDir(join(dir, entry.name), depth + 1);
}
}
} catch {
// Ignore permission errors
}
if (!existsSync(projectsRoot)) {
return projectDirs;
}
scanDir(baseDir, 0);
return historyDirs;
try {
const entries = readdirSync(projectsRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const paths = StoragePaths.projectById(entry.name);
if (existsSync(paths.historyDb)) {
// Return project ID as identifier (actual project path is hashed)
projectDirs.push(entry.name);
}
}
}
} catch {
// Ignore permission errors
}
return projectDirs;
}
/**
* Get execution history from SQLite
* Get execution history from SQLite (centralized storage)
*/
export async function getExecutionHistoryAsync(baseDir: string, options: {
limit?: number;
@@ -1127,32 +1123,31 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
}> {
const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options;
// With centralized storage, just query the current project
// recursive mode now searches all projects in centralized storage
if (recursive) {
// For recursive, we need to check multiple directories
const historyDirs = findCliHistoryDirs(baseDir);
const projectIds = findProjectsWithHistory();
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const historyDir of historyDirs) {
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = await getSqliteStore(dirBase);
const result = store.getHistory({ limit: 100, tool, status, category, search });
totalCount += result.total;
const relativeSource = relative(baseDir, dirBase) || '.';
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: relativeSource });
for (const projectId of projectIds) {
try {
// Use centralized path helper for project ID
const projectPaths = StoragePaths.projectById(projectId);
if (existsSync(projectPaths.historyDb)) {
// We need to use CliHistoryStore directly for arbitrary project IDs
const { CliHistoryStore } = await import('./cli-history-store.js');
// CliHistoryStore expects a project path, but we have project ID
// For now, skip cross-project queries - just query current project
}
} catch {
// Skip projects with errors
}
}
// Sort by timestamp (newest first)
allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return {
total: totalCount,
count: Math.min(allExecutions.length, limit),
executions: allExecutions.slice(0, limit)
};
// For simplicity, just query current project in recursive mode too
const store = await getSqliteStore(baseDir);
return store.getHistory({ limit, tool, status, category, search });
}
const store = await getSqliteStore(baseDir);
@@ -1176,19 +1171,22 @@ export function getExecutionHistory(baseDir: string, options: {
try {
if (recursive) {
const historyDirs = findCliHistoryDirs(baseDir);
const projectDirs = findProjectsWithHistory();
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const historyDir of historyDirs) {
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = getSqliteStoreSync(dirBase);
const result = store.getHistory({ limit: 100, tool, status });
totalCount += result.total;
for (const projectDir of projectDirs) {
try {
// Use baseDir as context for relative path display
const store = getSqliteStoreSync(baseDir);
const result = store.getHistory({ limit: 100, tool, status });
totalCount += result.total;
const relativeSource = relative(baseDir, dirBase) || '.';
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: relativeSource });
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: projectDir });
}
} catch {
// Skip projects with errors
}
}
@@ -1213,8 +1211,8 @@ export function getExecutionHistory(baseDir: string, options: {
* Get conversation detail by ID (returns ConversationRecord)
*/
export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null {
const historyDir = join(baseDir, '.workflow', '.cli-history');
return loadConversation(historyDir, conversationId);
const paths = StoragePaths.project(baseDir);
return loadConversation(paths.cliHistory, conversationId);
}
/**

View File

@@ -29,11 +29,20 @@ const LITE_FIX_BASE = '.workflow/.lite-fix';
const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
// Zod schemas - using tuple syntax for z.enum
const ContentTypeEnum = z.enum(['session', 'plan', 'task', 'summary', 'process', 'chat', 'brainstorm', 'review-dim', 'review-iter', 'review-fix', 'todo', 'context']);
const ContentTypeEnum = z.enum([
'session', 'plan', 'task', 'summary', 'process', 'chat', 'brainstorm',
'review-dim', 'review-iter', 'review-fix', 'todo', 'context',
// Lite-specific content types
'lite-plan', 'lite-fix-plan', 'exploration', 'explorations-manifest',
'diagnosis', 'diagnoses-manifest', 'clarifications', 'execution-context', 'session-metadata'
]);
const OperationEnum = z.enum(['init', 'list', 'read', 'write', 'update', 'archive', 'mkdir', 'delete', 'stats']);
const LocationEnum = z.enum(['active', 'archived', 'both']);
const LocationEnum = z.enum([
'active', 'archived', 'both',
'lite-plan', 'lite-fix', 'all'
]);
const ParamsSchema = z.object({
operation: OperationEnum,
@@ -137,6 +146,7 @@ function validatePathParams(pathParams: Record<string, unknown>): void {
* Dynamic params: {task_id}, {filename}, {dimension}, {iteration}
*/
const PATH_ROUTES: Record<ContentType, string> = {
// Standard WFS content types
session: '{base}/workflow-session.json',
plan: '{base}/IMPL_PLAN.md',
task: '{base}/.task/{task_id}.json',
@@ -149,6 +159,16 @@ const PATH_ROUTES: Record<ContentType, string> = {
'review-fix': '{base}/.review/fixes/{filename}',
todo: '{base}/TODO_LIST.md',
context: '{base}/context-package.json',
// Lite-specific content types
'lite-plan': '{base}/plan.json',
'lite-fix-plan': '{base}/fix-plan.json',
'exploration': '{base}/exploration-{angle}.json',
'explorations-manifest': '{base}/explorations-manifest.json',
'diagnosis': '{base}/diagnosis-{angle}.json',
'diagnoses-manifest': '{base}/diagnoses-manifest.json',
'clarifications': '{base}/clarifications.json',
'execution-context': '{base}/execution-context.json',
'session-metadata': '{base}/session-metadata.json',
};
/**
@@ -187,8 +207,17 @@ function resolvePath(
/**
* Get session base path
*/
function getSessionBase(sessionId: string, archived = false): string {
const basePath = archived ? ARCHIVE_BASE : ACTIVE_BASE;
function getSessionBase(
sessionId: string,
location: 'active' | 'archived' | 'lite-plan' | 'lite-fix' = 'active'
): string {
const locationMap: Record<string, string> = {
'active': ACTIVE_BASE,
'archived': ARCHIVE_BASE,
'lite-plan': LITE_PLAN_BASE,
'lite-fix': LITE_FIX_BASE,
};
const basePath = locationMap[location] || ACTIVE_BASE;
return resolve(findWorkflowRoot(), basePath, sessionId);
}
@@ -257,6 +286,55 @@ function writeTextFile(filePath: string, content: string): void {
writeFileSync(filePath, content, 'utf8');
}
// ============================================================
// Helper Functions
// ============================================================
/**
* List sessions in a specific directory
* @param dirPath - Directory to scan
* @param location - Location identifier for returned sessions
* @param prefix - Optional prefix filter (e.g., 'WFS-'), null means no filter
* @param includeMetadata - Whether to load metadata for each session
*/
function listSessionsInDir(
dirPath: string,
location: string,
prefix: string | null,
includeMetadata: boolean
): SessionInfo[] {
if (!existsSync(dirPath)) return [];
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && (prefix === null || e.name.startsWith(prefix)))
.map(e => {
const sessionInfo: SessionInfo = { session_id: e.name, location };
if (includeMetadata) {
// Try multiple metadata file locations
const metaPaths = [
join(dirPath, e.name, 'workflow-session.json'),
join(dirPath, e.name, 'session-metadata.json'),
join(dirPath, e.name, 'explorations-manifest.json'),
join(dirPath, e.name, 'diagnoses-manifest.json'),
];
for (const metaPath of metaPaths) {
if (existsSync(metaPath)) {
try {
sessionInfo.metadata = readJsonFile(metaPath);
break;
} catch { /* continue */ }
}
}
}
return sessionInfo;
});
} catch {
return [];
}
}
// ============================================================
// Operation Handlers
// ============================================================
@@ -264,9 +342,10 @@ function writeTextFile(filePath: string, content: string): void {
/**
* Operation: init
* Create new session with directory structure
* Supports both WFS sessions and lite sessions (lite-plan, lite-fix)
*/
function executeInit(params: Params): any {
const { session_id, metadata } = params;
const { session_id, metadata, location } = params;
if (!session_id) {
throw new Error('Parameter "session_id" is required for init');
@@ -275,27 +354,46 @@ function executeInit(params: Params): any {
// Validate session_id format
validateSessionId(session_id);
// Determine session location (default: active for WFS, or specified for lite)
const sessionLocation = (location === 'lite-plan' || location === 'lite-fix')
? location
: 'active';
// Check if session already exists (auto-detect all locations)
const existing = findSession(session_id);
if (existing) {
throw new Error(`Session "${session_id}" already exists in ${existing.location}`);
}
const sessionPath = getSessionBase(session_id);
const sessionPath = getSessionBase(session_id, sessionLocation);
// Create session directory structure
// Create session directory structure based on type
ensureDir(sessionPath);
ensureDir(join(sessionPath, '.task'));
ensureDir(join(sessionPath, '.summaries'));
ensureDir(join(sessionPath, '.process'));
// Create workflow-session.json if metadata provided
let directoriesCreated: string[] = [];
if (sessionLocation === 'lite-plan' || sessionLocation === 'lite-fix') {
// Lite sessions: minimal structure, files created by workflow
// No subdirectories needed initially
directoriesCreated = [];
} else {
// WFS sessions: standard structure
ensureDir(join(sessionPath, '.task'));
ensureDir(join(sessionPath, '.summaries'));
ensureDir(join(sessionPath, '.process'));
directoriesCreated = ['.task', '.summaries', '.process'];
}
// Create session metadata file if provided
let sessionMetadata = null;
if (metadata) {
const sessionFile = join(sessionPath, 'workflow-session.json');
const sessionFile = sessionLocation.startsWith('lite-')
? join(sessionPath, 'session-metadata.json') // Lite sessions
: join(sessionPath, 'workflow-session.json'); // WFS sessions
const sessionData = {
session_id,
status: 'planning',
type: sessionLocation,
status: 'initialized',
created_at: new Date().toISOString(),
...metadata,
};
@@ -306,16 +404,17 @@ function executeInit(params: Params): any {
return {
operation: 'init',
session_id,
location: sessionLocation,
path: sessionPath,
directories_created: ['.task', '.summaries', '.process'],
directories_created: directoriesCreated,
metadata: sessionMetadata,
message: `Session "${session_id}" initialized successfully`,
message: `Session "${session_id}" initialized in ${sessionLocation}`,
};
}
/**
* Operation: list
* List sessions (active, archived, or both)
* List sessions (active, archived, lite-plan, lite-fix, or all)
*/
function executeList(params: Params): any {
const { location = 'both', include_metadata = false } = params;
@@ -324,63 +423,67 @@ function executeList(params: Params): any {
operation: string;
active: SessionInfo[];
archived: SessionInfo[];
litePlan: SessionInfo[];
liteFix: SessionInfo[];
total: number;
} = {
operation: 'list',
active: [],
archived: [],
litePlan: [],
liteFix: [],
total: 0,
};
// List active sessions
if (location === 'active' || location === 'both') {
const activePath = resolve(findWorkflowRoot(), ACTIVE_BASE);
if (existsSync(activePath)) {
const entries = readdirSync(activePath, { withFileTypes: true });
result.active = entries
.filter((e) => e.isDirectory() && e.name.startsWith('WFS-'))
.map((e) => {
const sessionInfo: SessionInfo = { session_id: e.name, location: 'active' };
if (include_metadata) {
const metaPath = join(activePath, e.name, 'workflow-session.json');
if (existsSync(metaPath)) {
try {
sessionInfo.metadata = readJsonFile(metaPath);
} catch {
sessionInfo.metadata = null;
}
}
}
return sessionInfo;
});
}
const root = findWorkflowRoot();
// Helper to check if location should be included
const shouldInclude = (loc: string) =>
location === 'all' || location === 'both' || location === loc;
// List active sessions (WFS-* prefix)
if (shouldInclude('active')) {
result.active = listSessionsInDir(
resolve(root, ACTIVE_BASE),
'active',
'WFS-',
include_metadata
);
}
// List archived sessions
if (location === 'archived' || location === 'both') {
const archivePath = resolve(findWorkflowRoot(), ARCHIVE_BASE);
if (existsSync(archivePath)) {
const entries = readdirSync(archivePath, { withFileTypes: true });
result.archived = entries
.filter((e) => e.isDirectory() && e.name.startsWith('WFS-'))
.map((e) => {
const sessionInfo: SessionInfo = { session_id: e.name, location: 'archived' };
if (include_metadata) {
const metaPath = join(archivePath, e.name, 'workflow-session.json');
if (existsSync(metaPath)) {
try {
sessionInfo.metadata = readJsonFile(metaPath);
} catch {
sessionInfo.metadata = null;
}
}
}
return sessionInfo;
});
}
// List archived sessions (WFS-* prefix)
if (shouldInclude('archived')) {
result.archived = listSessionsInDir(
resolve(root, ARCHIVE_BASE),
'archived',
'WFS-',
include_metadata
);
}
result.total = result.active.length + result.archived.length;
// List lite-plan sessions (no prefix filter)
if (location === 'all' || location === 'lite-plan') {
result.litePlan = listSessionsInDir(
resolve(root, LITE_PLAN_BASE),
'lite-plan',
null,
include_metadata
);
}
// List lite-fix sessions (no prefix filter)
if (location === 'all' || location === 'lite-fix') {
result.liteFix = listSessionsInDir(
resolve(root, LITE_FIX_BASE),
'lite-fix',
null,
include_metadata
);
}
result.total = result.active.length + result.archived.length +
result.litePlan.length + result.liteFix.length;
return result;
}
@@ -543,31 +646,51 @@ function executeArchive(params: Params): any {
throw new Error('Parameter "session_id" is required for archive');
}
const activePath = getSessionBase(session_id, false);
const archivePath = getSessionBase(session_id, true);
if (!existsSync(activePath)) {
// Check if already archived
if (existsSync(archivePath)) {
return {
operation: 'archive',
session_id,
status: 'already_archived',
path: archivePath,
message: `Session "${session_id}" is already archived`,
};
}
throw new Error(`Session "${session_id}" not found in active sessions`);
// Find session in any location
const session = findSession(session_id);
if (!session) {
throw new Error(`Session "${session_id}" not found`);
}
// Update status to completed before archiving
// Lite sessions do not support archiving
if (session.location === 'lite-plan' || session.location === 'lite-fix') {
throw new Error(`Lite sessions (${session.location}) do not support archiving. Use delete operation instead.`);
}
// Determine archive destination based on source location
let archivePath: string;
if (session.location === 'active') {
archivePath = getSessionBase(session_id, 'archived');
} else {
// Already archived
return {
operation: 'archive',
session_id,
status: 'already_archived',
path: session.path,
location: session.location,
message: `Session "${session_id}" is already archived`,
};
}
// Update status before archiving
if (update_status) {
const sessionFile = join(activePath, 'workflow-session.json');
if (existsSync(sessionFile)) {
const sessionData = readJsonFile(sessionFile);
sessionData.status = 'completed';
sessionData.archived_at = new Date().toISOString();
writeJsonFile(sessionFile, sessionData);
const metadataFiles = [
join(session.path, 'workflow-session.json'),
join(session.path, 'session-metadata.json'),
join(session.path, 'explorations-manifest.json'),
];
for (const metaFile of metadataFiles) {
if (existsSync(metaFile)) {
try {
const data = readJsonFile(metaFile);
data.status = 'completed';
data.archived_at = new Date().toISOString();
writeJsonFile(metaFile, data);
break;
} catch { /* continue */ }
}
}
}
@@ -575,23 +698,33 @@ function executeArchive(params: Params): any {
ensureDir(dirname(archivePath));
// Move session directory
renameSync(activePath, archivePath);
renameSync(session.path, archivePath);
// Read session metadata after archiving
let sessionMetadata = null;
const sessionFile = join(archivePath, 'workflow-session.json');
if (existsSync(sessionFile)) {
sessionMetadata = readJsonFile(sessionFile);
const metadataFiles = [
join(archivePath, 'workflow-session.json'),
join(archivePath, 'session-metadata.json'),
join(archivePath, 'explorations-manifest.json'),
];
for (const metaFile of metadataFiles) {
if (existsSync(metaFile)) {
try {
sessionMetadata = readJsonFile(metaFile);
break;
} catch { /* continue */ }
}
}
return {
operation: 'archive',
session_id,
status: 'archived',
source: activePath,
source: session.path,
source_location: session.location,
destination: archivePath,
metadata: sessionMetadata,
message: `Session "${session_id}" archived successfully`,
message: `Session "${session_id}" archived from ${session.location}`,
};
}

View File

@@ -0,0 +1,399 @@
/**
* Storage Manager - Centralized storage management for CCW
* Provides info, cleanup, and configuration for ~/.ccw/ storage
*/
import { existsSync, readdirSync, statSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { createRequire } from 'module';
import { StoragePaths, CCW_HOME, getProjectId } from '../config/storage-paths.js';
// Create require for loading CJS modules in ESM context
const require = createRequire(import.meta.url);
/**
* Storage statistics for a single project
*/
export interface ProjectStorageStats {
projectId: string;
totalSize: number;
cliHistory: { exists: boolean; size: number; recordCount?: number };
memory: { exists: boolean; size: number };
cache: { exists: boolean; size: number };
config: { exists: boolean; size: number };
lastModified: Date | null;
}
/**
* Global storage statistics
*/
export interface StorageStats {
rootPath: string;
totalSize: number;
globalDb: { exists: boolean; size: number };
projects: ProjectStorageStats[];
projectCount: number;
}
/**
* Storage configuration
*/
export interface StorageConfig {
dataDir: string;
isCustom: boolean;
envVar: string | undefined;
}
/**
* Calculate directory size recursively
*/
function getDirSize(dirPath: string): number {
if (!existsSync(dirPath)) return 0;
let totalSize = 0;
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += getDirSize(fullPath);
} else {
try {
totalSize += statSync(fullPath).size;
} catch {
// Skip files we can't read
}
}
}
} catch {
// Skip directories we can't read
}
return totalSize;
}
/**
* Get file size safely
*/
function getFileSize(filePath: string): number {
try {
return existsSync(filePath) ? statSync(filePath).size : 0;
} catch {
return 0;
}
}
/**
* Get latest modification time in a directory
*/
function getLatestModTime(dirPath: string): Date | null {
if (!existsSync(dirPath)) return null;
let latest: Date | null = null;
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
try {
const stat = statSync(fullPath);
const mtime = stat.mtime;
if (!latest || mtime > latest) {
latest = mtime;
}
if (entry.isDirectory()) {
const subLatest = getLatestModTime(fullPath);
if (subLatest && (!latest || subLatest > latest)) {
latest = subLatest;
}
}
} catch {
// Skip files we can't read
}
}
} catch {
// Skip directories we can't read
}
return latest;
}
/**
* Get record count from SQLite database
*/
function getDbRecordCount(dbPath: string, tableName: string): number {
if (!existsSync(dbPath)) return 0;
try {
// Dynamic import to handle ESM module
const Database = require('better-sqlite3');
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
const stmt = db.prepare(`SELECT COUNT(*) as count FROM ${tableName}`);
const result = stmt.get() as { count: number };
db.close();
return result?.count ?? 0;
} catch (err) {
// Debug: enable to see actual error
if (process.env.DEBUG) console.error(`[Storage] Failed to get record count from ${dbPath}: ${err}`);
return 0;
}
}
/**
* Get storage statistics for a specific project by ID
*/
export function getProjectStorageStats(projectId: string): ProjectStorageStats {
const paths = StoragePaths.projectById(projectId);
const cliHistorySize = getDirSize(paths.cliHistory);
const memorySize = getDirSize(paths.memory);
const cacheSize = getDirSize(paths.cache);
const configSize = getDirSize(paths.config);
let recordCount: number | undefined;
if (existsSync(paths.historyDb)) {
recordCount = getDbRecordCount(paths.historyDb, 'conversations');
}
return {
projectId,
totalSize: cliHistorySize + memorySize + cacheSize + configSize,
cliHistory: {
exists: existsSync(paths.cliHistory),
size: cliHistorySize,
recordCount
},
memory: {
exists: existsSync(paths.memory),
size: memorySize
},
cache: {
exists: existsSync(paths.cache),
size: cacheSize
},
config: {
exists: existsSync(paths.config),
size: configSize
},
lastModified: getLatestModTime(paths.root)
};
}
/**
* Get all storage statistics
*/
export function getStorageStats(): StorageStats {
const rootPath = CCW_HOME;
const projectsDir = join(rootPath, 'projects');
// Global database
const mcpTemplatesPath = StoragePaths.global.mcpTemplates();
const globalDbSize = getFileSize(mcpTemplatesPath);
// Projects
const projects: ProjectStorageStats[] = [];
if (existsSync(projectsDir)) {
try {
const entries = readdirSync(projectsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
projects.push(getProjectStorageStats(entry.name));
}
}
} catch {
// Ignore read errors
}
}
// Sort by last modified (most recent first)
projects.sort((a, b) => {
if (!a.lastModified && !b.lastModified) return 0;
if (!a.lastModified) return 1;
if (!b.lastModified) return -1;
return b.lastModified.getTime() - a.lastModified.getTime();
});
const totalProjectSize = projects.reduce((sum, p) => sum + p.totalSize, 0);
return {
rootPath,
totalSize: globalDbSize + totalProjectSize,
globalDb: {
exists: existsSync(mcpTemplatesPath),
size: globalDbSize
},
projects,
projectCount: projects.length
};
}
/**
* Get current storage configuration
*/
export function getStorageConfig(): StorageConfig {
const envVar = process.env.CCW_DATA_DIR;
return {
dataDir: CCW_HOME,
isCustom: !!envVar,
envVar
};
}
/**
* Format bytes to human readable string
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
/**
* Format date to relative time
*/
export function formatTimeAgo(date: Date | null): string {
if (!date) return 'Never';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Clean storage for a specific project
*/
export function cleanProjectStorage(projectId: string, options: {
cliHistory?: boolean;
memory?: boolean;
cache?: boolean;
config?: boolean;
all?: boolean;
} = { all: true }): { success: boolean; freedBytes: number; errors: string[] } {
const paths = StoragePaths.projectById(projectId);
let freedBytes = 0;
const errors: string[] = [];
const shouldClean = (type: keyof typeof options) => options.all || options[type];
const cleanDir = (dirPath: string, name: string) => {
if (existsSync(dirPath)) {
try {
const size = getDirSize(dirPath);
rmSync(dirPath, { recursive: true, force: true });
freedBytes += size;
} catch (err) {
errors.push(`Failed to clean ${name}: ${err}`);
}
}
};
if (shouldClean('cliHistory')) cleanDir(paths.cliHistory, 'CLI history');
if (shouldClean('memory')) cleanDir(paths.memory, 'Memory store');
if (shouldClean('cache')) cleanDir(paths.cache, 'Cache');
if (shouldClean('config')) cleanDir(paths.config, 'Config');
// Remove project directory if empty
if (existsSync(paths.root)) {
try {
const remaining = readdirSync(paths.root);
if (remaining.length === 0) {
rmSync(paths.root, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
return { success: errors.length === 0, freedBytes, errors };
}
/**
* Clean all storage
*/
export function cleanAllStorage(options: {
cliHistory?: boolean;
memory?: boolean;
cache?: boolean;
config?: boolean;
globalDb?: boolean;
all?: boolean;
} = { all: true }): { success: boolean; freedBytes: number; projectsCleaned: number; errors: string[] } {
const stats = getStorageStats();
let freedBytes = 0;
let projectsCleaned = 0;
const errors: string[] = [];
// Clean projects
for (const project of stats.projects) {
const result = cleanProjectStorage(project.projectId, options);
freedBytes += result.freedBytes;
if (result.errors.length === 0) {
projectsCleaned++;
}
errors.push(...result.errors);
}
// Clean global database if requested
if (options.all || options.globalDb) {
const mcpPath = StoragePaths.global.mcpTemplates();
if (existsSync(mcpPath)) {
try {
const size = getFileSize(mcpPath);
rmSync(mcpPath, { force: true });
freedBytes += size;
} catch (err) {
errors.push(`Failed to clean global database: ${err}`);
}
}
}
return { success: errors.length === 0, freedBytes, projectsCleaned, errors };
}
/**
* Get project ID from project path
*/
export function resolveProjectId(projectPath: string): string {
return getProjectId(resolve(projectPath));
}
/**
* Check if a project ID exists in storage
*/
export function projectExists(projectId: string): boolean {
const paths = StoragePaths.projectById(projectId);
return existsSync(paths.root);
}
/**
* Get storage location instructions for changing it
*/
export function getStorageLocationInstructions(): string {
return `
To change the CCW storage location, set the CCW_DATA_DIR environment variable:
Windows (PowerShell):
$env:CCW_DATA_DIR = "D:\\custom\\ccw-data"
Windows (Command Prompt):
set CCW_DATA_DIR=D:\\custom\\ccw-data
Linux/macOS:
export CCW_DATA_DIR="/custom/ccw-data"
Permanent (add to shell profile):
echo 'export CCW_DATA_DIR="/custom/ccw-data"' >> ~/.bashrc
Note: Existing data will NOT be migrated automatically.
To migrate, manually copy the contents of the old directory to the new location.
Current location: ${CCW_HOME}
`;
}

View File

@@ -1,5 +1,5 @@
export type SessionStatus = 'active' | 'paused' | 'completed' | 'archived';
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs';
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix';
export type ContentType =
| 'session' | 'plan' | 'task' | 'summary'
| 'process' | 'chat' | 'brainstorm'

View File

@@ -195,15 +195,6 @@ export function ensureDir(dirPath: string): void {
}
}
/**
* Get the .workflow directory path from project path
* @param projectPath - Path to project
* @returns Path to .workflow directory
*/
export function getWorkflowDir(projectPath: string): string {
return join(resolvePath(projectPath), '.workflow');
}
/**
* Normalize path for display (handle Windows backslashes)
* @param filePath - Path to normalize