From e165d2ca2424a1d62f5aecdacfcf75c49227e04a Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Sat, 28 Mar 2026 11:30:36 +0300 Subject: [PATCH] feat: setup delete session by id --- server/src/modules/codex/codex.routes.js | 6 +- server/src/modules/gemini/gemini.routes.js | 4 +- .../projects/projects.inline.routes.js | 5 +- ...-parser.ts => claude.session-processor.ts} | 64 ++++++++ .../providers/codex/codex.session-parser.ts | 52 ------ .../codex/codex.session-processor.ts | 151 ++++++++++++++++++ ...-parser.ts => cursor.session-processor.ts} | 101 ++++++++++++ .../providers/gemini/gemini.session-parser.ts | 53 ------ .../gemini/gemini.session-processor.ts | 118 ++++++++++++++ .../sessions/sessions.inline.routes.js | 4 +- .../src/modules/sessions/sessions.service.ts | 54 +++++-- .../src/modules/sessions/sessions.watcher.ts | 8 +- .../database/repositories/sessions.db.ts | 68 +++++++- 13 files changed, 554 insertions(+), 134 deletions(-) rename server/src/modules/providers/claude/{claude.session-parser.ts => claude.session-processor.ts} (51%) delete mode 100644 server/src/modules/providers/codex/codex.session-parser.ts create mode 100644 server/src/modules/providers/codex/codex.session-processor.ts rename server/src/modules/providers/cursor/{cursor.session-parser.ts => cursor.session-processor.ts} (57%) delete mode 100644 server/src/modules/providers/gemini/gemini.session-parser.ts create mode 100644 server/src/modules/providers/gemini/gemini.session-processor.ts diff --git a/server/src/modules/codex/codex.routes.js b/server/src/modules/codex/codex.routes.js index 5f91c798..f61571e3 100644 --- a/server/src/modules/codex/codex.routes.js +++ b/server/src/modules/codex/codex.routes.js @@ -4,8 +4,9 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import TOML from '@iarna/toml'; -import { getCodexSessions, deleteCodexSession } from '../../../projects.js'; +import { getCodexSessions } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; const router = express.Router(); @@ -71,8 +72,7 @@ router.get('/sessions', async (req, res) => { router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; - await deleteCodexSession(sessionId); - sessionsDb.deleteSession(sessionId); + await deleteSessionFromProviders(sessionId); res.json({ success: true }); } catch (error) { console.error(`Error deleting Codex session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/gemini/gemini.routes.js b/server/src/modules/gemini/gemini.routes.js index 753fb8b2..1b9b1588 100644 --- a/server/src/modules/gemini/gemini.routes.js +++ b/server/src/modules/gemini/gemini.routes.js @@ -1,6 +1,6 @@ import express from 'express'; import sessionManager from '../../../sessionManager.js'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; const router = express.Router(); @@ -13,7 +13,7 @@ router.delete('/sessions/:sessionId', async (req, res) => { } await sessionManager.deleteSession(sessionId); - sessionsDb.deleteSession(sessionId); + await deleteSessionFromProviders(sessionId); res.json({ success: true }); } catch (error) { console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/projects/projects.inline.routes.js b/server/src/modules/projects/projects.inline.routes.js index 429a166a..2e1220da 100644 --- a/server/src/modules/projects/projects.inline.routes.js +++ b/server/src/modules/projects/projects.inline.routes.js @@ -6,12 +6,12 @@ import { getProjects, getSessions, renameProject, - deleteSession, deleteProject, searchConversations } from '../../../projects.js'; import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; +import { deleteSession as deleteSessionFromProviders } from '@/modules/sessions/sessions.service.js'; import { authenticateToken } from '../auth/auth.middleware.js'; import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js'; @@ -69,8 +69,7 @@ router.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToke try { const { projectName, sessionId } = req.params; console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`); - await deleteSession(projectName, sessionId); - sessionsDb.deleteSession(sessionId); + await deleteSessionFromProviders(sessionId); console.log(`[API] Session ${sessionId} deleted successfully`); res.json({ success: true }); } catch (error) { diff --git a/server/src/modules/providers/claude/claude.session-parser.ts b/server/src/modules/providers/claude/claude.session-processor.ts similarity index 51% rename from server/src/modules/providers/claude/claude.session-parser.ts rename to server/src/modules/providers/claude/claude.session-processor.ts index 62c0e538..ce79a59c 100644 --- a/server/src/modules/providers/claude/claude.session-parser.ts +++ b/server/src/modules/providers/claude/claude.session-processor.ts @@ -49,3 +49,67 @@ export async function processClaudeSessions() { } } } + +function encodeClaudeProjectPath(projectPath: string): string { + return projectPath.replace(/[^a-zA-Z0-9-]/g, '-'); +} + +async function removeFileIfExists(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function listDirectoryEntriesSafe(directoryPath: string): Promise { + try { + return await fsp.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +async function findFilesByName(rootPath: string, fileName: string): Promise { + const matches: string[] = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const currentPath = stack.pop() as string; + const entries = await listDirectoryEntriesSafe(currentPath); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name === fileName) { + matches.push(fullPath); + } + } + } + + return matches; +} + +export async function deleteClaudeSession(sessionId: string, workspacePath?: string): Promise { + const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects'); + const fileName = `${sessionId}.jsonl`; + let deleted = false; + + if (workspacePath) { + const encodedPath = encodeClaudeProjectPath(workspacePath); + const candidateFilePath = path.join(claudeProjectsDir, encodedPath, fileName); + deleted = (await removeFileIfExists(candidateFilePath)) || deleted; + } + + const matches = await findFilesByName(claudeProjectsDir, fileName); + for (const filePath of matches) { + deleted = (await removeFileIfExists(filePath)) || deleted; + } + + return deleted; +} diff --git a/server/src/modules/providers/codex/codex.session-parser.ts b/server/src/modules/providers/codex/codex.session-parser.ts deleted file mode 100644 index 5dbc05e6..00000000 --- a/server/src/modules/providers/codex/codex.session-parser.ts +++ /dev/null @@ -1,52 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fsp from 'node:fs/promises'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -export async function processCodexSessionFile(file: string, nameMap?: Map): Promise { - if (!nameMap) { - const base = path.join(os.homedir(), '.codex'); - nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - } - - // Codex nests the required data inside a `payload` object - return extractFirstValidJsonlData(file, (data) => ({ - workspacePath: data?.payload?.cwd, - sessionId: data?.payload?.id, - sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session' - })); -} - -export async function processCodexSessions() { - const base = path.join(os.homedir(), '.codex'); - // Use the thread_name attribute as requested - const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); - - const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl'); - - for (const file of files) { - const result = await processCodexSessionFile(file, nameMap); - - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'codex', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - ); - } - } -} diff --git a/server/src/modules/providers/codex/codex.session-processor.ts b/server/src/modules/providers/codex/codex.session-processor.ts new file mode 100644 index 00000000..bbf73886 --- /dev/null +++ b/server/src/modules/providers/codex/codex.session-processor.ts @@ -0,0 +1,151 @@ +import os from 'os'; +import path from 'path'; +import fsp from 'node:fs/promises'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { buildLookupMap, extractFirstValidJsonlData, findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; +import { SessionData } from '@/shared/types/session.js'; + +export async function processCodexSessionFile(file: string, nameMap?: Map): Promise { + if (!nameMap) { + const base = path.join(os.homedir(), '.codex'); + nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); + } + + // Codex nests the required data inside a `payload` object + return extractFirstValidJsonlData(file, (data) => ({ + workspacePath: data?.payload?.cwd, + sessionId: data?.payload?.id, + sessionName: nameMap!.get(data?.payload?.id) || 'Untitled Codex Session' + })); +} + +export async function processCodexSessions() { + const base = path.join(os.homedir(), '.codex'); + // Use the thread_name attribute as requested + const nameMap = await buildLookupMap(path.join(base, 'session_index.jsonl'), 'id', 'thread_name'); + + const files = await findFilesRecursivelyCreatedAfterLastScan(path.join(base, 'sessions'), '.jsonl'); + + for (const file of files) { + const result = await processCodexSessionFile(file, nameMap); + + if (result) { + let createdAt: string | undefined; + let updatedAt: string | undefined; + try { + const stat = await fsp.stat(file); + createdAt = stat.birthtime.toISOString(); + updatedAt = stat.mtime.toISOString(); + } catch { + // Ignore stat failures and let DB defaults handle created_at/updated_at. + } + sessionsDb.createSession( + result.sessionId, + 'codex', + result.workspacePath, + result.sessionName, + createdAt, + updatedAt, + ); + } + } +} + +function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> { + const parsedDate = new Date(createdAt); + if (Number.isNaN(parsedDate.getTime())) { + return []; + } + + const localDate = { + year: String(parsedDate.getFullYear()), + month: String(parsedDate.getMonth() + 1), + day: String(parsedDate.getDate()), + }; + + const utcDate = { + year: String(parsedDate.getUTCFullYear()), + month: String(parsedDate.getUTCMonth() + 1), + day: String(parsedDate.getUTCDate()), + }; + + if ( + localDate.year === utcDate.year && + localDate.month === utcDate.month && + localDate.day === utcDate.day + ) { + return [localDate]; + } + + return [localDate, utcDate]; +} + +async function removeFileIfExists(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function listDirectoryEntriesSafe(directoryPath: string): Promise { + try { + return await fsp.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +async function findFilesByName(rootPath: string, fileName: string): Promise { + const matches: string[] = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const currentPath = stack.pop() as string; + const entries = await listDirectoryEntriesSafe(currentPath); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name === fileName) { + matches.push(fullPath); + } + } + } + + return matches; +} + +export async function deleteCodexSession(sessionId: string, createdAt?: string): Promise { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const fileName = `${sessionId}.jsonl`; + let deleted = false; + + if (createdAt) { + const datePathParts = buildCodexDatePathParts(createdAt); + for (const parts of datePathParts) { + const candidateFilePath = path.join( + codexSessionsDir, + parts.year, + parts.month, + parts.day, + fileName, + ); + deleted = (await removeFileIfExists(candidateFilePath)) || deleted; + } + } + + if (!deleted) { + const matches = await findFilesByName(codexSessionsDir, fileName); + for (const filePath of matches) { + deleted = (await removeFileIfExists(filePath)) || deleted; + } + } + + return deleted; +} diff --git a/server/src/modules/providers/cursor/cursor.session-parser.ts b/server/src/modules/providers/cursor/cursor.session-processor.ts similarity index 57% rename from server/src/modules/providers/cursor/cursor.session-parser.ts rename to server/src/modules/providers/cursor/cursor.session-processor.ts index 03c7125e..9a689aa0 100644 --- a/server/src/modules/providers/cursor/cursor.session-parser.ts +++ b/server/src/modules/providers/cursor/cursor.session-processor.ts @@ -12,6 +12,84 @@ function md5(input: string): string { return crypto.createHash('md5').update(input).digest('hex'); } +async function removeFileIfExists(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function removeDirectoryIfExists(directoryPath: string): Promise { + try { + await fsp.rm(directoryPath, { recursive: true, force: false }); + return true; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function listDirectoryEntriesSafe(directoryPath: string): Promise { + try { + return await fsp.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +async function findDirectoriesByName(rootPath: string, directoryName: string): Promise { + const matches: string[] = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const currentPath = stack.pop() as string; + const entries = await listDirectoryEntriesSafe(currentPath); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const fullPath = path.join(currentPath, entry.name); + if (entry.name === directoryName) { + matches.push(fullPath); + } + + stack.push(fullPath); + } + } + + return matches; +} + +async function findFilesByName(rootPath: string, fileName: string): Promise { + const matches: string[] = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const currentPath = stack.pop() as string; + const entries = await listDirectoryEntriesSafe(currentPath); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name === fileName) { + matches.push(fullPath); + } + } + } + + return matches; +} + export async function extractWorkspacePathFromWorkerLog(filePath: string): Promise { try { const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); @@ -104,3 +182,26 @@ export async function processCursorSessions() { // Base cursor directory or projects directory likely doesn't exist } } + +export async function deleteCursorSession(sessionId: string, workspacePath?: string): Promise { + const cursorChatsDir = path.join(os.homedir(), '.cursor', 'chats'); + let deleted = false; + + if (workspacePath) { + const cwdId = md5(workspacePath); + const candidateDir = path.join(cursorChatsDir, cwdId, sessionId); + deleted = (await removeDirectoryIfExists(candidateDir)) || deleted; + } + + const sessionDirs = await findDirectoriesByName(cursorChatsDir, sessionId); + for (const directoryPath of sessionDirs) { + deleted = (await removeDirectoryIfExists(directoryPath)) || deleted; + } + + const jsonlFiles = await findFilesByName(cursorChatsDir, `${sessionId}.jsonl`); + for (const filePath of jsonlFiles) { + deleted = (await removeFileIfExists(filePath)) || deleted; + } + + return deleted; +} diff --git a/server/src/modules/providers/gemini/gemini.session-parser.ts b/server/src/modules/providers/gemini/gemini.session-parser.ts deleted file mode 100644 index 69bcbc5e..00000000 --- a/server/src/modules/providers/gemini/gemini.session-parser.ts +++ /dev/null @@ -1,53 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fsp from 'node:fs/promises'; -import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; -import { findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; -import { SessionData } from '@/shared/types/session.js'; - -export async function processGeminiSessionFile(file: string): Promise { - try { - // Gemini uses standard JSON (not JSONL), so we read the whole file at once - - const fileContent = await fsp.readFile(file, 'utf8'); - const data = JSON.parse(fileContent); - if (data?.id && data?.projectPath) { - return { - sessionId: data.id, - workspacePath: data.projectPath, - sessionName: data.messages?.[0]?.content || 'New Gemini Chat' - }; - } - } catch (e) { - // Ignore parsing error for gemini - } - return null; -} - -export async function processGeminiSessions() { - const geminiPath = path.join(os.homedir(), '.gemini', 'sessions'); - const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json'); - - for (const file of files) { - const result = await processGeminiSessionFile(file); - if (result) { - let createdAt: string | undefined; - let updatedAt: string | undefined; - try { - const stat = await fsp.stat(file); - createdAt = stat.birthtime.toISOString(); - updatedAt = stat.mtime.toISOString(); - } catch { - // Ignore stat failures and let DB defaults handle created_at/updated_at. - } - sessionsDb.createSession( - result.sessionId, - 'gemini', - result.workspacePath, - result.sessionName, - createdAt, - updatedAt, - ); - } - } -} diff --git a/server/src/modules/providers/gemini/gemini.session-processor.ts b/server/src/modules/providers/gemini/gemini.session-processor.ts new file mode 100644 index 00000000..8e1587e0 --- /dev/null +++ b/server/src/modules/providers/gemini/gemini.session-processor.ts @@ -0,0 +1,118 @@ +import os from 'os'; +import path from 'path'; +import fsp from 'node:fs/promises'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { findFilesRecursivelyCreatedAfterLastScan } from '@/modules/providers/shared/session-parser.utils.js'; +import { SessionData } from '@/shared/types/session.js'; + +export async function processGeminiSessionFile(file: string): Promise { + try { + // Gemini uses standard JSON (not JSONL), so we read the whole file at once + + const fileContent = await fsp.readFile(file, 'utf8'); + const data = JSON.parse(fileContent); + if (data?.id && data?.projectPath) { + return { + sessionId: data.id, + workspacePath: data.projectPath, + sessionName: data.messages?.[0]?.content || 'New Gemini Chat' + }; + } + } catch (e) { + // Ignore parsing error for gemini + } + return null; +} + +export async function processGeminiSessions() { + const geminiPath = path.join(os.homedir(), '.gemini', 'sessions'); + const files = await findFilesRecursivelyCreatedAfterLastScan(geminiPath, '.json'); + + for (const file of files) { + const result = await processGeminiSessionFile(file); + if (result) { + let createdAt: string | undefined; + let updatedAt: string | undefined; + try { + const stat = await fsp.stat(file); + createdAt = stat.birthtime.toISOString(); + updatedAt = stat.mtime.toISOString(); + } catch { + // Ignore stat failures and let DB defaults handle created_at/updated_at. + } + sessionsDb.createSession( + result.sessionId, + 'gemini', + result.workspacePath, + result.sessionName, + createdAt, + updatedAt, + ); + } + } +} + +async function removeFileIfExists(filePath: string): Promise { + try { + await fsp.unlink(filePath); + return true; + } catch (error: any) { + if (error?.code === 'ENOENT') { + return false; + } + throw error; + } +} + +async function listDirectoryEntriesSafe(directoryPath: string): Promise { + try { + return await fsp.readdir(directoryPath, { withFileTypes: true }); + } catch { + return []; + } +} + +export async function deleteGeminiSession(sessionId: string): Promise { + const geminiHome = path.join(os.homedir(), '.gemini'); + const geminiSessionsDir = path.join(geminiHome, 'sessions'); + const geminiTmpDir = path.join(geminiHome, 'tmp'); + let deleted = false; + + deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.json`))) || deleted; + deleted = (await removeFileIfExists(path.join(geminiSessionsDir, `${sessionId}.jsonl`))) || deleted; + + const projectDirs = await listDirectoryEntriesSafe(geminiTmpDir); + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) { + continue; + } + + const chatsDir = path.join(geminiTmpDir, projectDir.name, 'chats'); + const chatFiles = await listDirectoryEntriesSafe(chatsDir); + + for (const chatFile of chatFiles) { + if (!chatFile.isFile() || !chatFile.name.endsWith('.json')) { + continue; + } + + const chatFilePath = path.join(chatsDir, chatFile.name); + if (chatFile.name === `${sessionId}.json`) { + deleted = (await removeFileIfExists(chatFilePath)) || deleted; + continue; + } + + try { + const content = await fsp.readFile(chatFilePath, 'utf8'); + const parsed = JSON.parse(content); + const parsedId = parsed?.sessionId || parsed?.id; + if (parsedId === sessionId) { + deleted = (await removeFileIfExists(chatFilePath)) || deleted; + } + } catch { + // Ignore unreadable/malformed session files. + } + } + } + + return deleted; +} diff --git a/server/src/modules/sessions/sessions.inline.routes.js b/server/src/modules/sessions/sessions.inline.routes.js index ee0a1176..2f58cda1 100644 --- a/server/src/modules/sessions/sessions.inline.routes.js +++ b/server/src/modules/sessions/sessions.inline.routes.js @@ -24,10 +24,10 @@ router.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res if (summary.trim().length > 500) { return res.status(400).json({ error: 'Summary must not exceed 500 characters' }); } - if (!provider || !VALID_PROVIDERS.includes(provider)) { + if (provider && !VALID_PROVIDERS.includes(provider)) { return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` }); } - sessionsDb.createSessionName(safeSessionId, provider, summary.trim()); + sessionsDb.updateSessionCustomName(safeSessionId, summary.trim()); res.json({ success: true }); } catch (error) { console.error(`[API] Error renaming session ${req.params.sessionId}:`, error); diff --git a/server/src/modules/sessions/sessions.service.ts b/server/src/modules/sessions/sessions.service.ts index ae1fbd42..c47b86be 100644 --- a/server/src/modules/sessions/sessions.service.ts +++ b/server/src/modules/sessions/sessions.service.ts @@ -1,15 +1,26 @@ import { scanStateDb } from '@/shared/database/repositories/scan-state.db.js'; -import { processClaudeSessions } from '@/modules/providers/claude/claude.session-parser.js'; -import { processCodexSessions } from '@/modules/providers/codex/codex.session-parser.js'; -import { processGeminiSessions } from '@/modules/providers/gemini/gemini.session-parser.js'; -import { processCursorSessions } from '@/modules/providers/cursor/cursor.session-parser.js'; +import { sessionsDb } from '@/shared/database/repositories/sessions.db.js'; +import { processClaudeSessions, deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js'; +import { processCodexSessions, deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js'; +import { processGeminiSessions, deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js'; +import { processCursorSessions, deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js'; + +const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/; + +function sanitizeSessionId(sessionId: string): string { + const value = String(sessionId || '').trim(); + if (!SESSION_ID_PATTERN.test(value)) { + throw new Error('Invalid session ID format'); + } + return value; +} export async function processSessions() { // 1. Start the timer with a unique label - console.time("🚀 Workspace sync total time"); + console.time('Workspace sync total time'); - console.log("Starting workspace sync..."); + console.log('Starting workspace sync...'); try { // Wrapping in Promise.all allows these to process concurrently, speeding up the boot time await Promise.allSettled([ @@ -21,12 +32,33 @@ export async function processSessions() { scanStateDb.updateLastScannedAt(); } catch (error) { - console.error("An error occurred during sync:", error); + console.error('An error occurred during sync:', error); } finally { - console.log("----------------------------------"); + console.log('----------------------------------'); // 2. Stop the timer using the exact same label - // This will print: 🚀 Workspace sync total time: 123.456ms - console.timeEnd("🚀 Workspace sync total time"); - console.log("Workspace synchronization complete."); + // This will print: Workspace sync total time: 123.456ms + console.timeEnd('Workspace sync total time'); + console.log('Workspace synchronization complete.'); } } + +export async function deleteSession(sessionId: string): Promise { + const safeSessionId = sanitizeSessionId(sessionId); + const existingSession = sessionsDb.getSessionById(safeSessionId); + const workspacePath = existingSession?.workspace_path; + const createdAt = existingSession?.created_at; + + const deletionResults = await Promise.allSettled([ + deleteClaudeSession(safeSessionId, workspacePath), + deleteCodexSession(safeSessionId, createdAt), + deleteGeminiSession(safeSessionId), + deleteCursorSession(safeSessionId, workspacePath), + ]); + + const rejectedResult = deletionResults.find((result) => result.status === 'rejected') as PromiseRejectedResult | undefined; + if (rejectedResult) { + throw rejectedResult.reason; + } + + sessionsDb.deleteSession(safeSessionId); +} diff --git a/server/src/modules/sessions/sessions.watcher.ts b/server/src/modules/sessions/sessions.watcher.ts index 7eaab745..4c678c97 100644 --- a/server/src/modules/sessions/sessions.watcher.ts +++ b/server/src/modules/sessions/sessions.watcher.ts @@ -4,10 +4,10 @@ import os from "os"; import { promises as fsPromises } from "fs"; import { logger } from "@/shared/utils/logger.js"; import { processSessions } from "@/modules/sessions/sessions.service.js"; -import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-parser.js"; -import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-parser.js"; -import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-parser.js"; -import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-parser.js"; +import { processClaudeSessionFile } from "@/modules/providers/claude/claude.session-processor.js"; +import { processCodexSessionFile } from "@/modules/providers/codex/codex.session-processor.js"; +import { processGeminiSessionFile } from "@/modules/providers/gemini/gemini.session-processor.js"; +import { processCursorSessionFile } from "@/modules/providers/cursor/cursor.session-processor.js"; import { sessionsDb } from "@/shared/database/repositories/sessions.db.js"; import { LLMProvider } from "@/shared/types/app.js"; diff --git a/server/src/shared/database/repositories/sessions.db.ts b/server/src/shared/database/repositories/sessions.db.ts index 40d71df9..18cf7212 100644 --- a/server/src/shared/database/repositories/sessions.db.ts +++ b/server/src/shared/database/repositories/sessions.db.ts @@ -1,6 +1,7 @@ import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js'; import { getConnection } from '@/shared/database/connection.js'; -import type { SessionWithSummary } from '@/shared/database/types.js'; +import path from 'node:path'; +import type { SessionsRow, SessionWithSummary } from '@/shared/database/types.js'; // --------------------------------------------------------------------------- // Queries @@ -11,6 +12,11 @@ type SessionNameLookupRow = { custom_name: string; }; +type SessionMetadataLookupRow = Pick< + SessionsRow, + 'session_id' | 'provider' | 'workspace_path' | 'created_at' | 'updated_at' +>; + function normalizeTimestamp(value?: string): string | null { if (!value) return null; @@ -22,6 +28,34 @@ function normalizeTimestamp(value?: string): string | null { return parsed.toISOString(); } +function normalizeCodexWorkspacePath(workspacePath: string): string { + const trimmedPath = workspacePath.trim(); + if (!trimmedPath) { + return workspacePath; + } + + if (process.platform !== 'win32') { + return path.normalize(trimmedPath); + } + + let strippedPath = trimmedPath; + if (strippedPath.startsWith('\\\\?\\UNC\\')) { + strippedPath = `\\\\${strippedPath.slice('\\\\?\\UNC\\'.length)}`; + } else if (strippedPath.startsWith('\\\\?\\')) { + strippedPath = strippedPath.slice('\\\\?\\'.length); + } + + return path.win32.normalize(strippedPath); +} + +function normalizeWorkspacePathForProvider(provider: string, workspacePath: string): string { + if (provider !== 'codex') { + return workspacePath; + } + + return normalizeCodexWorkspacePath(workspacePath); +} + export const sessionsDb = { createSession( @@ -35,17 +69,30 @@ export const sessionsDb = { const db = getConnection(); const createdAtValue = normalizeTimestamp(createdAt); const updatedAtValue = normalizeTimestamp(updatedAt); + const normalizedWorkspacePath = normalizeWorkspacePathForProvider(provider, workspacePath); // First, ensure the workspace path is recorded in the workspace_original_paths table // since it's a foreign key in the sessions table. - workspaceOriginalPathsDb.createWorkspacePath(workspacePath); + workspaceOriginalPathsDb.createWorkspacePath(normalizedWorkspacePath); db.prepare( `INSERT INTO sessions (session_id, provider, custom_name, workspace_path, created_at, updated_at) VALUES (?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP), COALESCE(?, CURRENT_TIMESTAMP)) - ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at + ON CONFLICT(session_id) DO UPDATE SET + updated_at = excluded.updated_at, + workspace_path = excluded.workspace_path WHERE sessions.provider = excluded.provider` - ).run(session_id, provider, customName, workspacePath, createdAtValue, updatedAtValue); + ).run(session_id, provider, customName, normalizedWorkspacePath, createdAtValue, updatedAtValue); + }, + + /** Updates a custom session name by session id, regardless of provider. */ + updateSessionCustomName(sessionId: string, customName: string): void { + const db = getConnection(); + db.prepare( + `UPDATE sessions + SET custom_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE session_id = ?` + ).run(customName, sessionId); }, /** Updates a custom session name for an existing session row. */ @@ -58,6 +105,19 @@ export const sessionsDb = { ).run(customName, sessionId, provider); }, + getSessionById(sessionId: string): SessionMetadataLookupRow | null { + const db = getConnection(); + const row = db + .prepare( + `SELECT session_id, provider, workspace_path, created_at, updated_at + FROM sessions + WHERE session_id = ?` + ) + .get(sessionId) as SessionMetadataLookupRow | undefined; + + return row ?? null; + }, + getSessionName(sessionId: string, provider: string): string | null { const db = getConnection(); const row = db