feat: Add workflow dashboard template and utility functions

- Implemented a new HTML template for the workflow dashboard, featuring a responsive design with dark/light theme support, session statistics, and task management UI.
- Created a browser launcher utility to open HTML files in the default browser across platforms.
- Developed file utility functions for safe reading and writing of JSON and text files.
- Added path resolver utilities to validate and resolve file paths, ensuring security against path traversal attacks.
- Introduced UI utilities for displaying styled messages and banners in the console.
This commit is contained in:
catlog22
2025-12-04 09:40:12 +08:00
parent 0f9adc59f9
commit 35bd0aa8f6
22 changed files with 8272 additions and 24 deletions

View File

@@ -0,0 +1,577 @@
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Bundled template paths
const WORKFLOW_TEMPLATE = join(__dirname, '../templates/workflow-dashboard.html');
const REVIEW_TEMPLATE = join(__dirname, '../templates/review-cycle-dashboard.html');
/**
* Generate dashboard HTML from aggregated data
* Uses bundled templates from ccw package
* @param {Object} data - Aggregated dashboard data
* @returns {Promise<string>} - Generated HTML
*/
export async function generateDashboard(data) {
// Use bundled workflow template
if (existsSync(WORKFLOW_TEMPLATE)) {
return generateFromBundledTemplate(data, WORKFLOW_TEMPLATE);
}
// Fallback to inline dashboard if template missing
return generateInlineDashboard(data);
}
/**
* Generate dashboard using bundled template
* @param {Object} data - Dashboard data
* @param {string} templatePath - Path to workflow-dashboard.html
* @returns {string} - Generated HTML
*/
function generateFromBundledTemplate(data, templatePath) {
let html = readFileSync(templatePath, 'utf8');
// Prepare workflow data for injection
const workflowData = {
activeSessions: data.activeSessions,
archivedSessions: data.archivedSessions
};
// Inject workflow data
html = html.replace('{{WORKFLOW_DATA}}', JSON.stringify(workflowData, null, 2));
// If we have review data, add a review tab
if (data.reviewData && data.reviewData.totalFindings > 0) {
html = injectReviewTab(html, data.reviewData);
}
return html;
}
/**
* Inject review tab into existing dashboard
* @param {string} html - Base dashboard HTML
* @param {Object} reviewData - Review data to display
* @returns {string} - Modified HTML with review tab
*/
function injectReviewTab(html, reviewData) {
// Add review tab button in header controls
const tabButtonHtml = `
<button class="btn" data-tab="reviews" id="reviewTabBtn">Reviews (${reviewData.totalFindings})</button>
`;
// Insert after filter-group
html = html.replace(
'</div>\n </div>\n </header>',
`</div>
<div class="filter-group" style="margin-left: auto;">
${tabButtonHtml}
</div>
</div>
</header>`
);
// Add review section before closing container
const reviewSectionHtml = generateReviewSection(reviewData);
html = html.replace(
'</div>\n\n <button class="theme-toggle"',
`</div>
${reviewSectionHtml}
</div>
<button class="theme-toggle"`
);
// Add review tab JavaScript
const reviewScript = generateReviewScript(reviewData);
html = html.replace('</script>', `\n${reviewScript}\n</script>`);
return html;
}
/**
* Generate review section HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML for review section
*/
function generateReviewSection(reviewData) {
const severityBars = Object.entries(reviewData.severityDistribution)
.map(([severity, count]) => {
const colors = {
critical: '#c53030',
high: '#f56565',
medium: '#ed8936',
low: '#48bb78'
};
const percent = reviewData.totalFindings > 0
? Math.round((count / reviewData.totalFindings) * 100)
: 0;
return `
<div class="severity-bar-item">
<span class="severity-label">${severity}</span>
<div class="severity-bar">
<div class="severity-fill" style="width: ${percent}%; background-color: ${colors[severity]}"></div>
</div>
<span class="severity-count">${count}</span>
</div>
`;
}).join('');
const dimensionCards = Object.entries(reviewData.dimensionSummary)
.map(([name, info]) => `
<div class="dimension-card">
<div class="dimension-name">${name}</div>
<div class="dimension-count">${info.count} findings</div>
</div>
`).join('');
return `
<div class="section" id="reviewSectionContainer" style="display: none;">
<div class="section-header">
<h2 class="section-title">Code Review Findings</h2>
</div>
<div class="review-stats">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${reviewData.severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${reviewData.severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${reviewData.severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${reviewData.severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="severity-distribution">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">Severity Distribution</h3>
${severityBars}
</div>
<div class="dimensions-grid" style="margin-top: 30px;">
<h3 style="margin-bottom: 15px; color: var(--text-secondary);">By Dimension</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
${dimensionCards}
</div>
</div>
<style>
.review-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.severity-distribution {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.severity-bar-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.severity-label {
width: 80px;
text-transform: capitalize;
font-size: 0.9rem;
}
.severity-bar {
flex: 1;
height: 20px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.severity-fill {
height: 100%;
transition: width 0.3s;
}
.severity-count {
width: 40px;
text-align: right;
font-weight: bold;
}
.dimension-card {
background: var(--bg-card);
padding: 15px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.dimension-name {
font-weight: 600;
text-transform: capitalize;
margin-bottom: 5px;
}
.dimension-count {
color: var(--text-secondary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.review-stats {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</div>
`;
}
/**
* Generate JavaScript for review tab functionality
* @param {Object} reviewData - Review data
* @returns {string} - JavaScript code
*/
function generateReviewScript(reviewData) {
return `
// Review tab functionality
const reviewTabBtn = document.getElementById('reviewTabBtn');
const reviewSection = document.getElementById('reviewSectionContainer');
const activeSectionContainer = document.getElementById('activeSectionContainer');
const archivedSectionContainer = document.getElementById('archivedSectionContainer');
if (reviewTabBtn) {
reviewTabBtn.addEventListener('click', () => {
const isActive = reviewTabBtn.classList.contains('active');
// Toggle review section
if (isActive) {
// Hide reviews, show workflow
reviewTabBtn.classList.remove('active');
reviewSection.style.display = 'none';
activeSectionContainer.style.display = 'block';
archivedSectionContainer.style.display = 'block';
} else {
// Show reviews, hide workflow
reviewTabBtn.classList.add('active');
reviewSection.style.display = 'block';
activeSectionContainer.style.display = 'none';
archivedSectionContainer.style.display = 'none';
// Reset filter buttons
document.querySelectorAll('[data-filter]').forEach(b => b.classList.remove('active'));
document.querySelector('[data-filter="all"]').classList.add('active');
}
});
}
`;
}
/**
* Generate inline dashboard HTML (fallback if bundled templates missing)
* @param {Object} data - Dashboard data
* @returns {string}
*/
function generateInlineDashboard(data) {
const stats = data.statistics;
const hasReviews = data.reviewData && data.reviewData.totalFindings > 0;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CCW Dashboard</title>
<style>
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-card: #ffffff;
--text-primary: #1a202c;
--text-secondary: #718096;
--border-color: #e2e8f0;
--accent-color: #4299e1;
--success-color: #48bb78;
--warning-color: #ed8936;
--danger-color: #f56565;
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-card: #2d3748;
--text-primary: #f7fafc;
--text-secondary: #a0aec0;
--border-color: #4a5568;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
background: var(--bg-secondary);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 30px;
}
h1 { font-size: 2rem; color: var(--accent-color); margin-bottom: 10px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.stat-value { font-size: 2rem; font-weight: bold; color: var(--accent-color); }
.stat-label { color: var(--text-secondary); font-size: 0.9rem; }
.section { margin-bottom: 40px; }
.section-title { font-size: 1.5rem; margin-bottom: 20px; }
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.session-card {
background: var(--bg-card);
padding: 20px;
border-radius: 8px;
box-shadow: var(--shadow);
}
.session-title { font-size: 1.2rem; font-weight: 600; margin-bottom: 10px; }
.session-meta { color: var(--text-secondary); font-size: 0.9rem; }
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
margin: 15px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), var(--success-color));
}
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-primary);
border-radius: 6px;
border-left: 3px solid var(--border-color);
}
.task-item.completed { border-left-color: var(--success-color); opacity: 0.8; }
.task-item.in_progress { border-left-color: var(--warning-color); }
.task-title { flex: 1; font-size: 0.9rem; }
.task-id { font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-secondary); }
.tabs { display: flex; gap: 10px; margin-top: 15px; }
.tab-btn {
padding: 10px 20px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-card);
color: var(--text-primary);
cursor: pointer;
}
.tab-btn.active { background: var(--accent-color); color: white; border-color: var(--accent-color); }
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--accent-color);
color: white;
border: none;
cursor: pointer;
font-size: 1.5rem;
box-shadow: var(--shadow);
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>CCW Dashboard</h1>
<p style="color: var(--text-secondary);">Workflow Sessions and Reviews</p>
<div class="tabs">
<button class="tab-btn active" data-tab="workflow">Workflow</button>
${hasReviews ? '<button class="tab-btn" data-tab="reviews">Reviews</button>' : ''}
</div>
</header>
<div id="workflowTab">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${stats.totalSessions}</div>
<div class="stat-label">Total Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.activeSessions}</div>
<div class="stat-label">Active Sessions</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalTasks}</div>
<div class="stat-label">Total Tasks</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.completedTasks}</div>
<div class="stat-label">Completed Tasks</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Active Sessions</h2>
<div class="sessions-grid" id="activeSessions">
${data.activeSessions.length === 0
? '<div class="empty-state">No active sessions</div>'
: data.activeSessions.map(s => renderSessionCard(s, true)).join('')}
</div>
</div>
<div class="section">
<h2 class="section-title">Archived Sessions</h2>
<div class="sessions-grid" id="archivedSessions">
${data.archivedSessions.length === 0
? '<div class="empty-state">No archived sessions</div>'
: data.archivedSessions.map(s => renderSessionCard(s, false)).join('')}
</div>
</div>
</div>
${hasReviews ? renderReviewTab(data.reviewData) : ''}
</div>
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
<script>
function toggleTheme() {
const html = document.documentElement;
const current = html.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
document.querySelector('.theme-toggle').textContent = next === 'dark' ? '☀️' : '🌙';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
document.querySelector('.theme-toggle').textContent = savedTheme === 'dark' ? '☀️' : '🌙';
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.dataset.tab;
document.getElementById('workflowTab').style.display = tab === 'workflow' ? 'block' : 'none';
const reviewTab = document.getElementById('reviewsTab');
if (reviewTab) reviewTab.style.display = tab === 'reviews' ? 'block' : 'none';
});
});
</script>
</body>
</html>`;
}
/**
* Render a session card
* @param {Object} session - Session data
* @param {boolean} isActive - Whether session is active
* @returns {string} - HTML string
*/
function renderSessionCard(session, isActive) {
const completedTasks = isActive
? session.tasks.filter(t => t.status === 'completed').length
: session.taskCount;
const totalTasks = isActive ? session.tasks.length : session.taskCount;
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
const tasksHtml = isActive && session.tasks.length > 0
? session.tasks.map(t => `
<div class="task-item ${t.status}">
<div class="task-title">${t.title}</div>
<span class="task-id">${t.task_id}</span>
</div>
`).join('')
: '';
return `
<div class="session-card">
<div class="session-title">${session.session_id}</div>
<div class="session-meta">
${session.project ? `<div>${session.project}</div>` : ''}
<div>${session.created_at} | ${completedTasks}/${totalTasks} tasks</div>
</div>
${totalTasks > 0 ? `
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
` : ''}
${tasksHtml}
</div>
`;
}
/**
* Render review tab HTML
* @param {Object} reviewData - Review data
* @returns {string} - HTML string
*/
function renderReviewTab(reviewData) {
const { severityDistribution, dimensionSummary } = reviewData;
return `
<div id="reviewsTab" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" style="color: #c53030;">${severityDistribution.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #f56565;">${severityDistribution.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #ed8936;">${severityDistribution.medium}</div>
<div class="stat-label">Medium</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: #48bb78;">${severityDistribution.low}</div>
<div class="stat-label">Low</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Findings by Dimension</h2>
<div class="sessions-grid">
${Object.entries(dimensionSummary).map(([name, info]) => `
<div class="session-card">
<div class="session-title" style="text-transform: capitalize;">${name}</div>
<div class="session-meta">${info.count} findings</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,288 @@
import { glob } from 'glob';
import { readFileSync, existsSync } from 'fs';
import { join, basename } from 'path';
/**
* Aggregate all data for dashboard rendering
* @param {Object} sessions - Scanned sessions from session-scanner
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<Object>} - Aggregated dashboard data
*/
export async function aggregateData(sessions, workflowDir) {
const data = {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
reviewData: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0
}
};
// Process active sessions
for (const session of sessions.active) {
const sessionData = await processSession(session, true);
data.activeSessions.push(sessionData);
data.statistics.totalTasks += sessionData.tasks.length;
data.statistics.completedTasks += sessionData.tasks.filter(t => t.status === 'completed').length;
}
// Process archived sessions
for (const session of sessions.archived) {
const sessionData = await processSession(session, false);
data.archivedSessions.push(sessionData);
data.statistics.totalTasks += sessionData.taskCount || 0;
data.statistics.completedTasks += sessionData.taskCount || 0;
}
// Aggregate review data if present
if (sessions.hasReviewData) {
data.reviewData = await aggregateReviewData(sessions.active);
data.statistics.reviewFindings = data.reviewData.totalFindings;
}
data.statistics.totalSessions = sessions.active.length + sessions.archived.length;
data.statistics.activeSessions = sessions.active.length;
return data;
}
/**
* Process a single session, loading tasks and review info
* @param {Object} session - Session object from scanner
* @param {boolean} isActive - Whether session is active
* @returns {Promise<Object>} - Processed session data
*/
async function processSession(session, isActive) {
const result = {
session_id: session.session_id,
project: session.project || session.session_id,
status: session.status || (isActive ? 'active' : 'archived'),
created_at: formatDate(session.created_at),
archived_at: formatDate(session.archived_at),
path: session.path,
tasks: [],
taskCount: 0,
hasReview: false,
reviewSummary: null
};
// Load tasks for active sessions (full details)
if (isActive) {
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
for (const taskFile of taskFiles) {
try {
const taskData = JSON.parse(readFileSync(join(taskDir, taskFile), 'utf8'));
result.tasks.push({
task_id: taskData.id || basename(taskFile, '.json'),
title: taskData.title || 'Untitled Task',
status: taskData.status || 'pending',
type: taskData.meta?.type || 'task'
});
} catch {
// Skip invalid task files
}
}
// Sort tasks by ID
result.tasks.sort((a, b) => sortTaskIds(a.task_id, b.task_id));
}
result.taskCount = result.tasks.length;
// Check for review data
const reviewDir = join(session.path, '.review');
if (existsSync(reviewDir)) {
result.hasReview = true;
result.reviewSummary = loadReviewSummary(reviewDir);
}
} else {
// For archived, just count tasks
const taskDir = join(session.path, '.task');
if (existsSync(taskDir)) {
const taskFiles = await safeGlob('IMPL-*.json', taskDir);
result.taskCount = taskFiles.length;
}
}
return result;
}
/**
* Aggregate review data from all active sessions with reviews
* @param {Array} activeSessions - Active session objects
* @returns {Promise<Object>} - Aggregated review data
*/
async function aggregateReviewData(activeSessions) {
const reviewData = {
totalFindings: 0,
severityDistribution: { critical: 0, high: 0, medium: 0, low: 0 },
dimensionSummary: {},
sessions: []
};
for (const session of activeSessions) {
const reviewDir = join(session.path, '.review');
if (!existsSync(reviewDir)) continue;
const reviewProgress = loadReviewProgress(reviewDir);
const dimensionData = await loadDimensionData(reviewDir);
if (reviewProgress || dimensionData.length > 0) {
const sessionReview = {
session_id: session.session_id,
progress: reviewProgress,
dimensions: dimensionData,
findings: []
};
// Collect and count findings
for (const dim of dimensionData) {
if (dim.findings && Array.isArray(dim.findings)) {
for (const finding of dim.findings) {
const severity = (finding.severity || 'low').toLowerCase();
if (reviewData.severityDistribution.hasOwnProperty(severity)) {
reviewData.severityDistribution[severity]++;
}
reviewData.totalFindings++;
sessionReview.findings.push({
...finding,
dimension: dim.name
});
}
}
// Track dimension summary
if (!reviewData.dimensionSummary[dim.name]) {
reviewData.dimensionSummary[dim.name] = { count: 0, sessions: [] };
}
reviewData.dimensionSummary[dim.name].count += dim.findings?.length || 0;
reviewData.dimensionSummary[dim.name].sessions.push(session.session_id);
}
reviewData.sessions.push(sessionReview);
}
}
return reviewData;
}
/**
* Load review progress from review-progress.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewProgress(reviewDir) {
const progressFile = join(reviewDir, 'review-progress.json');
if (!existsSync(progressFile)) return null;
try {
return JSON.parse(readFileSync(progressFile, 'utf8'));
} catch {
return null;
}
}
/**
* Load review summary from review-state.json
* @param {string} reviewDir - Path to .review directory
* @returns {Object|null}
*/
function loadReviewSummary(reviewDir) {
const stateFile = join(reviewDir, 'review-state.json');
if (!existsSync(stateFile)) return null;
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
return {
phase: state.phase || 'unknown',
severityDistribution: state.severity_distribution || {},
criticalFiles: (state.critical_files || []).slice(0, 3),
status: state.status || 'in_progress'
};
} catch {
return null;
}
}
/**
* Load dimension data from .review/dimensions/
* @param {string} reviewDir - Path to .review directory
* @returns {Promise<Array>}
*/
async function loadDimensionData(reviewDir) {
const dimensionsDir = join(reviewDir, 'dimensions');
if (!existsSync(dimensionsDir)) return [];
const dimensions = [];
const dimFiles = await safeGlob('*.json', dimensionsDir);
for (const file of dimFiles) {
try {
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
dimensions.push({
name: basename(file, '.json'),
findings: Array.isArray(data) ? data : (data.findings || []),
status: data.status || 'completed'
});
} catch {
// Skip invalid dimension files
}
}
return dimensions;
}
/**
* Safe glob wrapper that returns empty array on error
* @param {string} pattern - Glob pattern
* @param {string} cwd - Current working directory
* @returns {Promise<string[]>}
*/
async function safeGlob(pattern, cwd) {
try {
return await glob(pattern, { cwd, absolute: false });
} catch {
return [];
}
}
/**
* Format date for display
* @param {string|null} dateStr - ISO date string
* @returns {string}
*/
function formatDate(dateStr) {
if (!dateStr) return 'N/A';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
}
/**
* Sort task IDs numerically (IMPL-1, IMPL-2, IMPL-1.1, etc.)
* @param {string} a - First task ID
* @param {string} b - Second task ID
* @returns {number}
*/
function sortTaskIds(a, b) {
const parseId = (id) => {
const match = id.match(/IMPL-(\d+)(?:\.(\d+))?/);
if (!match) return [0, 0];
return [parseInt(match[1]), parseInt(match[2] || 0)];
};
const [a1, a2] = parseId(a);
const [b1, b2] = parseId(b);
return a1 - b1 || a2 - b2;
}

201
ccw/src/core/manifest.js Normal file
View File

@@ -0,0 +1,201 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
// Manifest directory location
const MANIFEST_DIR = join(homedir(), '.claude-manifests');
/**
* Ensure manifest directory exists
*/
function ensureManifestDir() {
if (!existsSync(MANIFEST_DIR)) {
mkdirSync(MANIFEST_DIR, { recursive: true });
}
}
/**
* Create a new installation manifest
* @param {string} mode - Installation mode (Global/Path)
* @param {string} installPath - Installation path
* @returns {Object} - New manifest object
*/
export function createManifest(mode, installPath) {
ensureManifestDir();
const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').split('.')[0];
const modePrefix = mode === 'Global' ? 'manifest-global' : 'manifest-path';
const manifestId = `${modePrefix}-${timestamp}`;
return {
manifest_id: manifestId,
version: '1.0',
installation_mode: mode,
installation_path: installPath,
installation_date: new Date().toISOString(),
installer_version: '1.0.0',
files: [],
directories: []
};
}
/**
* Add file entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} filePath - File path
*/
export function addFileEntry(manifest, filePath) {
manifest.files.push({
path: filePath,
type: 'File',
timestamp: new Date().toISOString()
});
}
/**
* Add directory entry to manifest
* @param {Object} manifest - Manifest object
* @param {string} dirPath - Directory path
*/
export function addDirectoryEntry(manifest, dirPath) {
manifest.directories.push({
path: dirPath,
type: 'Directory',
timestamp: new Date().toISOString()
});
}
/**
* Save manifest to disk
* @param {Object} manifest - Manifest object
* @returns {string} - Path to saved manifest
*/
export function saveManifest(manifest) {
ensureManifestDir();
// Remove old manifests for same path and mode
removeOldManifests(manifest.installation_path, manifest.installation_mode);
const manifestPath = join(MANIFEST_DIR, `${manifest.manifest_id}.json`);
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
return manifestPath;
}
/**
* Remove old manifests for the same installation path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
*/
function removeOldManifests(installPath, mode) {
if (!existsSync(MANIFEST_DIR)) return;
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
const manifestPath = (content.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
const manifestMode = content.installation_mode || 'Global';
if (manifestPath === normalizedPath && manifestMode === mode) {
unlinkSync(filePath);
}
} catch {
// Skip invalid manifest files
}
}
} catch {
// Ignore errors
}
}
/**
* Get all installation manifests
* @returns {Array} - Array of manifest objects
*/
export function getAllManifests() {
if (!existsSync(MANIFEST_DIR)) return [];
const manifests = [];
try {
const files = readdirSync(MANIFEST_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = join(MANIFEST_DIR, file);
const content = JSON.parse(readFileSync(filePath, 'utf8'));
// Try to read version.json for application version
let appVersion = 'unknown';
try {
const versionPath = join(content.installation_path, '.claude', 'version.json');
if (existsSync(versionPath)) {
const versionInfo = JSON.parse(readFileSync(versionPath, 'utf8'));
appVersion = versionInfo.version || 'unknown';
}
} catch {
// Ignore
}
manifests.push({
...content,
manifest_file: filePath,
application_version: appVersion,
files_count: content.files?.length || 0,
directories_count: content.directories?.length || 0
});
} catch {
// Skip invalid manifest files
}
}
// Sort by installation date (newest first)
manifests.sort((a, b) => new Date(b.installation_date) - new Date(a.installation_date));
} catch {
// Ignore errors
}
return manifests;
}
/**
* Find manifest for a specific path and mode
* @param {string} installPath - Installation path
* @param {string} mode - Installation mode
* @returns {Object|null} - Manifest or null
*/
export function findManifest(installPath, mode) {
const manifests = getAllManifests();
const normalizedPath = installPath.toLowerCase().replace(/[\\/]+$/, '');
return manifests.find(m => {
const manifestPath = (m.installation_path || '').toLowerCase().replace(/[\\/]+$/, '');
return manifestPath === normalizedPath && m.installation_mode === mode;
}) || null;
}
/**
* Delete a manifest file
* @param {string} manifestFile - Path to manifest file
*/
export function deleteManifest(manifestFile) {
if (existsSync(manifestFile)) {
unlinkSync(manifestFile);
}
}
/**
* Get manifest directory path
* @returns {string}
*/
export function getManifestDir() {
return MANIFEST_DIR;
}

View File

@@ -0,0 +1,159 @@
import { glob } from 'glob';
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
import { join, basename } from 'path';
/**
* Scan .workflow directory for active and archived sessions
* @param {string} workflowDir - Path to .workflow directory
* @returns {Promise<{active: Array, archived: Array, hasReviewData: boolean}>}
*/
export async function scanSessions(workflowDir) {
const result = {
active: [],
archived: [],
hasReviewData: false
};
if (!existsSync(workflowDir)) {
return result;
}
// Scan active sessions
const activeDir = join(workflowDir, 'active');
if (existsSync(activeDir)) {
const activeSessions = await findWfsSessions(activeDir);
for (const sessionName of activeSessions) {
const sessionPath = join(activeDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.active.push({
...sessionData,
path: sessionPath,
isActive: true
});
// Check for review data
if (existsSync(join(sessionPath, '.review'))) {
result.hasReviewData = true;
}
}
}
}
// Scan archived sessions
const archivesDir = join(workflowDir, 'archives');
if (existsSync(archivesDir)) {
const archivedSessions = await findWfsSessions(archivesDir);
for (const sessionName of archivedSessions) {
const sessionPath = join(archivesDir, sessionName);
const sessionData = readSessionData(sessionPath);
if (sessionData) {
result.archived.push({
...sessionData,
path: sessionPath,
isActive: false
});
}
}
}
// Sort by creation date (newest first)
result.active.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
result.archived.sort((a, b) => new Date(b.archived_at || b.created_at || 0) - new Date(a.archived_at || a.created_at || 0));
return result;
}
/**
* Find WFS-* directories in a given path
* @param {string} dir - Directory to search
* @returns {Promise<string[]>} - Array of session directory names
*/
async function findWfsSessions(dir) {
try {
// Use glob for cross-platform pattern matching
const sessions = await glob('WFS-*', {
cwd: dir,
onlyDirectories: true,
absolute: false
});
return sessions;
} catch {
// Fallback: manual directory listing
try {
const entries = readdirSync(dir, { withFileTypes: true });
return entries
.filter(e => e.isDirectory() && e.name.startsWith('WFS-'))
.map(e => e.name);
} catch {
return [];
}
}
}
/**
* Read session data from workflow-session.json or create minimal from directory
* @param {string} sessionPath - Path to session directory
* @returns {Object|null} - Session data object or null if invalid
*/
function readSessionData(sessionPath) {
const sessionFile = join(sessionPath, 'workflow-session.json');
if (existsSync(sessionFile)) {
try {
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
return {
session_id: data.session_id || basename(sessionPath),
project: data.project || data.description || '',
status: data.status || 'active',
created_at: data.created_at || data.initialized_at || null,
archived_at: data.archived_at || null,
type: data.type || 'workflow'
};
} catch {
// Fall through to minimal session
}
}
// Fallback: create minimal session from directory info
try {
const stats = statSync(sessionPath);
return {
session_id: basename(sessionPath),
project: '',
status: 'unknown',
created_at: stats.birthtime.toISOString(),
archived_at: null,
type: 'workflow'
};
} catch {
return null;
}
}
/**
* Check if session has review data
* @param {string} sessionPath - Path to session directory
* @returns {boolean}
*/
export function hasReviewData(sessionPath) {
const reviewDir = join(sessionPath, '.review');
return existsSync(reviewDir);
}
/**
* Get list of task files in session
* @param {string} sessionPath - Path to session directory
* @returns {Promise<string[]>}
*/
export async function getTaskFiles(sessionPath) {
const taskDir = join(sessionPath, '.task');
if (!existsSync(taskDir)) {
return [];
}
try {
return await glob('IMPL-*.json', { cwd: taskDir, absolute: false });
} catch {
return [];
}
}