Files
Claude-Code-Workflow/ccw/src/core/session-scanner.js

236 lines
6.7 KiB
JavaScript

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 [];
}
}
}
/**
* Parse timestamp from session name
* Supports formats: WFS-xxx-20251128172537 or WFS-xxx-20251120-170640
* @param {string} sessionName - Session directory name
* @returns {string|null} - ISO date string or null
*/
function parseTimestampFromName(sessionName) {
// Format: 14-digit timestamp (YYYYMMDDHHmmss)
const match14 = sessionName.match(/(\d{14})$/);
if (match14) {
const ts = match14[1];
return `${ts.slice(0,4)}-${ts.slice(4,6)}-${ts.slice(6,8)}T${ts.slice(8,10)}:${ts.slice(10,12)}:${ts.slice(12,14)}Z`;
}
// Format: 8-digit date + 6-digit time separated by hyphen (YYYYMMDD-HHmmss)
const match8_6 = sessionName.match(/(\d{8})-(\d{6})$/);
if (match8_6) {
const d = match8_6[1];
const t = match8_6[2];
return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}T${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}Z`;
}
return null;
}
/**
* Infer session type from session name pattern
* @param {string} sessionName - Session directory name
* @returns {string} - Inferred type
*/
function inferTypeFromName(sessionName) {
const name = sessionName.toLowerCase();
if (name.includes('-review-') || name.includes('-code-review-')) {
return 'review';
}
if (name.includes('-test-')) {
return 'test';
}
if (name.includes('-docs-')) {
return 'docs';
}
if (name.includes('-tdd-')) {
return 'tdd';
}
return 'workflow';
}
/**
* 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');
const sessionName = basename(sessionPath);
if (existsSync(sessionFile)) {
try {
const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
// Multi-level type detection: JSON type > workflow_type > infer from name
let type = data.type || data.workflow_type || inferTypeFromName(sessionName);
// Normalize workflow_type values
if (type === 'test_session') type = 'test';
if (type === 'implementation') type = 'workflow';
return {
session_id: data.session_id || sessionName,
project: data.project || data.description || '',
status: data.status || 'active',
created_at: data.created_at || data.initialized_at || data.timestamp || null,
archived_at: data.archived_at || null,
type: type,
workflow_type: data.workflow_type || null // Keep original for reference
};
} catch {
// Fall through to minimal session
}
}
// Fallback: create minimal session from directory info
// Try to extract timestamp from session name first
const timestampFromName = parseTimestampFromName(sessionName);
const inferredType = inferTypeFromName(sessionName);
try {
const stats = statSync(sessionPath);
return {
session_id: sessionName,
project: '',
status: 'unknown',
created_at: timestampFromName || stats.birthtime.toISOString(),
archived_at: null,
type: inferredType,
workflow_type: null
};
} catch {
// Even if stat fails, return with name-extracted data
if (timestampFromName) {
return {
session_id: sessionName,
project: '',
status: 'unknown',
created_at: timestampFromName,
archived_at: null,
type: inferredType,
workflow_type: null
};
}
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 [];
}
}