Files
claudecodeui/server/modules/projects/services/projects-with-sessions-fetch.service.ts
Haileyesus bb86236520 refactor: modularize project services, and wizard create/clone flow
Restructure project creation, listing, GitHub clone progress, and TaskMaster
details behind a dedicated TypeScript module under server/modules/projects/,
and align the client wizard with a single path-based flow.

Server / routing
- Remove server/routes/projects.js and mount server/modules/projects/
  projects.routes.ts at /api/projects (still behind authenticateToken).
- Drop duplicate handlers from server/index.js for GET /api/projects and
  GET /api/projects/:projectId/taskmaster; those live on the new router.
- Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in
  index.js instead of the deleted projects route module.

Projects router (projects.routes.ts)
- GET /: list projects with sessions (existing snapshot behavior).
- POST /create-project: validate body, reject legacy workspaceType and
  mixed clone fields, delegate to createProject service, return distinct
  success copy when an archived path is reactivated.
- GET /clone-progress: Server-Sent Events for clone progress/complete/error;
  requires authenticated user id for token resolution; wires startCloneProject.
- GET /:projectId/taskmaster: delegates to getProjectTaskMaster.

Services (new)
- project-management.service.ts: path validation, workspace directory
  creation, persistence via projectsDb.createProjectPath, mapping to API
  project shape; surfaces AppError for validation, conflict, and not-found
  cases; optional dependency injection for tests.
- project-clone.service.ts: validates workspace, resolves GitHub auth
  (stored token or inline token), runs git clone with progress callbacks,
  registers project via createProject on success; sanitizes errors and
  supports cancellation; injectable dependencies for tests.
- projects-has-taskmaster.service.ts: moves TaskMaster detection and
  normalization out of server/projects.js; resolve-by-id and public
  getProjectTaskMaster with structured AppError responses.

Persistence and shared types
- projectsDb.createProjectPath now returns CreateProjectPathResult
  (created | reactivated_archived | active_conflict) using INSERT … ON
  CONFLICT with selective update when the row is archived; normalizes
  display name from path or custom name; repository row typing moves to
  shared ProjectRepositoryRow.
- getProjectPaths() returns only non-archived rows (isArchived = 0).
- shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome,
  WorkspacePathValidationResult.
- shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath,
  asyncHandler for Express async routes.

Legacy cleanup
- server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo,
  and getProjectTaskMasterById (logic lives in the new service).
- server/routes/agent.js: register external API project paths with
  projectsDb.createProjectPath instead of addProjectManually try/catch;
  treat active_conflict as an existing registration and continue.

Tests
- Add Node test suites for project-management, project-clone, and
  projects-has-taskmaster services; update projects.service test import
  for renamed projects-with-sessions-fetch.service.ts.

Rename
- projects.service.ts → projects-with-sessions-fetch.service.ts;
  re-export from modules/projects/index.ts.

Client (project creation wizard)
- Remove StepTypeSelection and workspaceType from form state and types;
  wizard is two steps (configure path/GitHub auth, then review).
- createWorkspaceRequest → createProjectRequest; clone vs create-only
  inferred from githubUrl (pathUtils / isCloneWorkflow).
- Adjust step indices, WizardProgress, StepConfiguration/Review,
  WorkspacePathField, and src/utils/api.js as needed for the new API.

Docs
- Minor websocket README touch-up.

Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is
centralized in typed services with explicit HTTP errors and test seams.
2026-04-25 19:36:47 +03:00

256 lines
7.2 KiB
TypeScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { sessionSynchronizerService } from '@/modules/providers/index.js';
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
import type { RealtimeClientConnection } from '@/shared/types.js';
import { findAppRoot, getModuleDir } from '@/utils/runtime-paths.js';
type SessionSummary = {
id: string;
summary: string;
messageCount: number;
lastActivity: string;
};
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
export type ProjectListItem = {
projectId: string;
path: string;
displayName: string;
fullPath: string;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
geminiSessions: SessionSummary[];
sessionMeta: {
hasMore: boolean;
total: number;
};
};
export type ProjectsSnapshot = {
generatedAt: string;
projectCount: number;
projects: ProjectListItem[];
};
type ProgressUpdate = {
phase: 'loading' | 'complete';
current: number;
total: number;
currentProject?: string;
};
const __dirname = getModuleDir(import.meta.url);
const APP_ROOT = findAppRoot(__dirname);
const PROJECTS_DUMP_DIR = path.join(APP_ROOT, '.tmp', 'project-dumps');
let projectsSnapshotCounter: number | null = null;
/**
* Generate better display name from path.
*/
export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise<string> {
// Use actual project directory if provided, otherwise decode from project name.
const projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path.
try {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageData = await fs.readFile(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageData) as { name?: string };
// Return the name from package.json if it exists.
if (packageJson.name) {
return packageJson.name;
}
} catch {
// Fall back to path-based naming if package.json doesn't exist or can't be read.
}
// If it starts with /, it's an absolute path.
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
// Return only the last folder name.
return parts[parts.length - 1] || projectPath;
}
return projectPath;
}
/**
* Group the `sessions` table rows for a project by provider.
*/
function buildSessionsByProviderFromDb(projectPath: string): SessionsByProvider {
const rows = sessionsDb.getSessionsByProjectPath(projectPath) as Array<{
provider: string;
session_id: string;
custom_name?: string | null;
updated_at?: string | null;
created_at?: string | null;
}>;
const byProvider: SessionsByProvider = {
claude: [],
cursor: [],
codex: [],
gemini: [],
};
for (const row of rows) {
const provider = row.provider as keyof SessionsByProvider;
const bucket = byProvider[provider];
if (!bucket) {
continue;
}
bucket.push({
id: row.session_id,
summary: row.custom_name || '',
messageCount: 0,
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
});
}
for (const provider of Object.keys(byProvider) as Array<keyof SessionsByProvider>) {
byProvider[provider].sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
}
return byProvider;
}
async function getNextProjectsSnapshotPath(): Promise<string> {
await fs.mkdir(PROJECTS_DUMP_DIR, { recursive: true });
if (projectsSnapshotCounter === null) {
const entries = await fs.readdir(PROJECTS_DUMP_DIR).catch(() => []);
projectsSnapshotCounter = entries.reduce((max, entry) => {
const match = entry.match(/^projects-(\d+)\.json$/);
if (!match) {
return max;
}
return Math.max(max, Number(match[1]));
}, 0);
}
projectsSnapshotCounter += 1;
const suffix = String(projectsSnapshotCounter).padStart(4, '0');
return path.join(PROJECTS_DUMP_DIR, `projects-${suffix}.json`);
}
/**
* Builds a typed snapshot payload for project dumps.
*/
export function createProjectsSnapshot(projects: ProjectListItem[]): ProjectsSnapshot {
return {
generatedAt: new Date().toISOString(),
projectCount: projects.length,
projects,
};
}
/**
* Writes a projects snapshot file as an incrementing artifact.
*/
export async function writeSnapshot(projects: ProjectListItem[]): Promise<void> {
try {
const snapshot = createProjectsSnapshot(projects);
const snapshotJson = JSON.stringify(snapshot, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 2);
while (true) {
const snapshotPath = await getNextProjectsSnapshotPath();
try {
await fs.writeFile(snapshotPath, snapshotJson, { encoding: 'utf8', flag: 'wx' });
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
continue;
}
throw error;
}
}
} catch (error) {
console.warn('Could not write projects snapshot:', (error as Error).message);
}
}
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress: ProgressUpdate) {
const message = JSON.stringify({
type: 'loading_progress',
...progress,
});
connectedClients.forEach((client: RealtimeClientConnection) => {
if (client.readyState === WS_OPEN_STATE) {
client.send(message);
}
});
}
/**
* Reads all projects from DB and returns provider-bucketed session summaries.
*/
export async function getProjectsWithSessions(): Promise<ProjectListItem[]> {
await sessionSynchronizerService.synchronizeSessions();
const projectRows = projectsDb.getProjectPaths() as Array<{
project_id: string;
project_path: string;
custom_project_name?: string | null;
}>;
const totalProjects = projectRows.length;
const projects: ProjectListItem[] = [];
let processedProjects = 0;
for (const row of projectRows) {
processedProjects += 1;
const projectId = row.project_id;
const projectPath = row.project_path;
broadcastProgress({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: projectPath,
});
const displayName =
row.custom_project_name && row.custom_project_name.trim().length > 0
? row.custom_project_name
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
const sessionsByProvider = buildSessionsByProviderFromDb(projectPath);
const claudeSessionsAll = sessionsByProvider.claude;
const claudeSessions = claudeSessionsAll.slice(0, 5);
projects.push({
projectId,
path: projectPath,
displayName,
fullPath: projectPath,
sessions: claudeSessions,
cursorSessions: sessionsByProvider.cursor,
codexSessions: sessionsByProvider.codex,
geminiSessions: sessionsByProvider.gemini,
sessionMeta: {
hasMore: false,
total: claudeSessionsAll.length,
},
});
}
broadcastProgress({
phase: 'complete',
current: totalProjects,
total: totalProjects,
});
await writeSnapshot(projects);
return projects;
}