diff --git a/server/index.js b/server/index.js index 6542a9fe..60f14311 100755 --- a/server/index.js +++ b/server/index.js @@ -70,7 +70,6 @@ import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; -import messagesRoutes from './routes/messages.js'; import providerRoutes from './modules/providers/provider.routes.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionsDb } from './modules/database/index.js'; @@ -194,9 +193,6 @@ app.use('/api/gemini', authenticateToken, geminiRoutes); // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); -// Unified session messages route (protected) -app.use('/api/sessions', authenticateToken, messagesRoutes); - // Unified provider MCP routes (protected) app.use('/api/providers', authenticateToken, providerRoutes); diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index eacf3f00..ffd358f3 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; -import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; @@ -103,13 +102,10 @@ async function parseAgentTools(filePath: string): Promise { } async function getSessionMessages( - projectName: string, sessionId: string, limit: number | null, offset: number, ): Promise { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - try { const jsonLPath = sessionsDb.getSessionById(sessionId)?.jsonl_path; @@ -117,6 +113,7 @@ async function getSessionMessages( return { messages: [], total: 0, hasMore: false }; } + const projectDir = path.dirname(jsonLPath); const files = await fsp.readdir(projectDir); const agentFiles = files.filter((file) => file.endsWith('.jsonl') && file.startsWith('agent-')); @@ -413,14 +410,11 @@ export class ClaudeSessionsProvider implements IProviderSessions { sessionId: string, options: FetchHistoryOptions = {}, ): Promise { - const { projectName, limit = null, offset = 0 } = options; - if (!projectName) { - return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; - } + const { limit = null, offset = 0 } = options; let result: ClaudeHistoryResult; try { - result = await getSessionMessages(projectName, sessionId, limit, offset); + result = await getSessionMessages(sessionId, limit, offset); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message); diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index 0ce8f2de..97cdd242 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -49,6 +49,29 @@ const readOptionalQueryString = (value: unknown): string | undefined => { return normalized.length > 0 ? normalized : undefined; }; +const parseOptionalBooleanQuery = (value: unknown, name: string): boolean | undefined => { + if (value === undefined) { + return undefined; + } + + const normalized = readOptionalQueryString(value); + if (!normalized) { + return undefined; + } + + if (normalized === 'true') { + return true; + } + if (normalized === 'false') { + return false; + } + + throw new AppError(`${name} must be "true" or "false".`, { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); +}; + const parseMcpScope = (value: unknown): McpScope | undefined => { if (value === undefined) { return undefined; @@ -260,7 +283,8 @@ router.delete( '/sessions/:sessionId', asyncHandler(async (req: Request, res: Response) => { const sessionId = parseSessionId(req.params.sessionId); - const result = await sessionsService.deleteSessionById(sessionId); + const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? false; + const result = await sessionsService.deleteSessionById(sessionId, deletedFromDisk); res.json(createApiSuccessResponse(result)); }), ); @@ -275,4 +299,36 @@ router.put( }), ); +router.get( + '/sessions/:sessionId/messages', + asyncHandler(async (req: Request, res: Response) => { + const sessionId = parseSessionId(req.params.sessionId); + const limitRaw = readOptionalQueryString(req.query.limit); + const offsetRaw = readOptionalQueryString(req.query.offset); + + const limit = limitRaw === undefined ? null : Number.parseInt(limitRaw, 10); + const offset = offsetRaw === undefined ? 0 : Number.parseInt(offsetRaw, 10); + + if (limitRaw !== undefined && Number.isNaN(limit)) { + throw new AppError('limit must be a valid integer.', { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); + } + + if (offsetRaw !== undefined && Number.isNaN(offset)) { + throw new AppError('offset must be a valid integer.', { + code: 'INVALID_QUERY_PARAMETER', + statusCode: 400, + }); + } + + const result = await sessionsService.fetchHistory(sessionId, { + limit, + offset, + }); + res.json(result); + }), +); + export default router; diff --git a/server/modules/providers/services/sessions.service.ts b/server/modules/providers/services/sessions.service.ts index 1afa1677..32572e95 100644 --- a/server/modules/providers/services/sessions.service.ts +++ b/server/modules/providers/services/sessions.service.ts @@ -10,7 +10,6 @@ import type { } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; - /** * Removes one file if it exists. */ @@ -54,20 +53,54 @@ export const sessionsService = { }, /** - * Fetches normalized persisted session history for one provider/session pair. + * Fetches persisted history by session id. + * + * Provider and provider-specific lookup hints are resolved from the indexed + * session metadata in the database. */ fetchHistory( - providerName: string, sessionId: string, - options?: FetchHistoryOptions, + options: Pick = {}, ): Promise { - return providerRegistry.resolveProvider(providerName).sessions.fetchHistory(sessionId, options); + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + const provider = session.provider as LLMProvider; + return providerRegistry.resolveProvider(provider).sessions.fetchHistory(sessionId, { + limit: options.limit ?? null, + offset: options.offset ?? 0, + projectPath: session.project_path ?? '', + }); }, /** * Deletes one persisted session row by id. + * + * When `deletedFromDisk` is true and a session `jsonl_path` exists, the path + * is deleted from disk before the DB row is removed. */ - deleteSessionById(sessionId: string): { sessionId: string } { + async deleteSessionById( + sessionId: string, + deletedFromDisk = false, + ): Promise<{ sessionId: string; deletedFromDisk: boolean }> { + const session = sessionsDb.getSessionById(sessionId); + if (!session) { + throw new AppError(`Session "${sessionId}" was not found.`, { + code: 'SESSION_NOT_FOUND', + statusCode: 404, + }); + } + + let removedFromDisk = false; + if (deletedFromDisk && session.jsonl_path) { + removedFromDisk = await removeFileIfExists(session.jsonl_path); + } + const deleted = sessionsDb.deleteSessionById(sessionId); if (!deleted) { throw new AppError(`Session "${sessionId}" was not found.`, { @@ -76,7 +109,7 @@ export const sessionsService = { }); } - return { sessionId }; + return { sessionId, deletedFromDisk: removedFromDisk }; }, /** diff --git a/server/projects.js b/server/projects.js index af95c1da..3cc0bbdc 100755 --- a/server/projects.js +++ b/server/projects.js @@ -16,7 +16,7 @@ * The filesystem-aware helpers kept in this module serve the remaining * features that still need on-disk data: * - Session message reads for each provider (Claude/Codex/Gemini) for - * `GET /api/sessions/:sessionId/messages`. + * `GET /api/providers/sessions/:sessionId/messages`. * - Conversation search (`searchConversations`) which scans JSONL history. * - (Project row removal / JSONL cleanup is handled in * `modules/projects/services/project-delete.service.ts`.) diff --git a/server/routes/messages.js b/server/routes/messages.js deleted file mode 100644 index 8aec2dd2..00000000 --- a/server/routes/messages.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Unified messages endpoint. - * - * GET /api/sessions/:sessionId/messages?provider=claude&projectId=&limit=50&offset=0 - * - * Replaces the four provider-specific session message endpoints with a single route - * that delegates to the appropriate adapter via the provider registry. - * - * After the projectName → projectId migration, Claude history is located via the - * DB-backed project path lookup; the route accepts `projectId` (preferred) and - * resolves it to the underlying Claude folder name for the downstream adapter. - * - * @module routes/messages - */ - -import express from 'express'; -import { sessionsService } from '../modules/providers/services/sessions.service.js'; -import { getProjectPathById, claudeFolderNameFromPath } from '../projects.js'; - -const router = express.Router(); - -/** - * GET /api/sessions/:sessionId/messages - * - * Auth: authenticateToken applied at mount level in index.js - * - * Query params: - * provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude') - * projectId - DB primary key of the project (required for claude provider) - * projectPath - required for cursor provider (absolute path used for cwdId hash) - * limit - page size (omit or null for all) - * offset - pagination offset (default: 0) - */ -router.get('/:sessionId/messages', async (req, res) => { - try { - const { sessionId } = req.params; - const provider = String(req.query.provider || 'claude').trim().toLowerCase(); - const projectId = req.query.projectId || ''; - const projectPath = req.query.projectPath || ''; - const limitParam = req.query.limit; - const limit = limitParam !== undefined && limitParam !== null && limitParam !== '' - ? parseInt(limitParam, 10) - : null; - const offset = parseInt(req.query.offset || '0', 10); - - const availableProviders = sessionsService.listProviderIds(); - if (!availableProviders.includes(provider)) { - const available = availableProviders.join(', '); - return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` }); - } - - // The Claude adapter still reads sessions from ~/.claude/projects//, - // so we translate the caller's projectId into the encoded folder name via - // the DB-stored project path before delegating to the adapter. - let claudeProjectName = ''; - if (provider === 'claude' && projectId) { - const resolvedPath = await getProjectPathById(projectId); - if (!resolvedPath) { - return res.status(404).json({ error: 'Project not found' }); - } - claudeProjectName = claudeFolderNameFromPath(resolvedPath); - } - - const result = await sessionsService.fetchHistory(provider, sessionId, { - projectName: claudeProjectName, - projectPath, - limit, - offset, - }); - - return res.json(result); - } catch (error) { - console.error('Error fetching unified messages:', error); - return res.status(500).json({ error: 'Failed to fetch messages' }); - } -}); - -export default router; diff --git a/server/shared/types.ts b/server/shared/types.ts index 33b183bc..d15f69e7 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -133,11 +133,10 @@ export type NormalizedMessage = { /** * Shared options used to fetch historical provider messages. * - * Consumers should pass provider-specific lookup hints (`projectName`, `projectPath`) - * only when the selected provider requires them. + * Consumers should pass provider-specific lookup hints (`projectPath`) only + * when the selected provider requires them. */ export type FetchHistoryOptions = { - projectName?: string; projectPath?: string; limit?: number | null; offset?: number; diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index d327507e..ef581e12 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -8,8 +8,9 @@ */ import { useCallback, useMemo, useRef, useState } from 'react'; -import type { LLMProvider } from '../types/app'; + import { authenticatedFetch } from '../utils/api'; +import type { LLMProvider } from '../types/app'; // ─── NormalizedMessage (mirrors server/adapters/types.js) ──────────────────── @@ -164,11 +165,9 @@ export function useSessionStore() { const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []); /** - * Fetch messages from the unified endpoint and populate serverMessages. + * Fetch messages from the provider sessions endpoint and populate serverMessages. * - * `projectId` is the DB-assigned identifier used by the backend to resolve - * the project's on-disk directory; it replaces the legacy `projectName` - * Claude folder encoding that callers used to pass. + * Provider and project metadata are resolved server-side from `sessionId`. */ const fetchFromServer = useCallback(async ( sessionId: string, @@ -186,16 +185,13 @@ export function useSessionStore() { try { const params = new URLSearchParams(); - if (opts.provider) params.append('provider', opts.provider); - if (opts.projectId) params.append('projectId', opts.projectId); - if (opts.projectPath) params.append('projectPath', opts.projectPath); if (opts.limit !== null && opts.limit !== undefined) { params.append('limit', String(opts.limit)); params.append('offset', String(opts.offset ?? 0)); } const qs = params.toString(); - const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const response = await authenticatedFetch(url); if (!response.ok) { @@ -228,9 +224,6 @@ export function useSessionStore() { /** * Load older (paginated) messages and prepend to serverMessages. - * - * Accepts `projectId` (the DB primary key) so the unified messages endpoint - * can resolve the project path through the database. */ const fetchMore = useCallback(async ( sessionId: string, @@ -245,15 +238,12 @@ export function useSessionStore() { if (!slot.hasMore) return slot; const params = new URLSearchParams(); - if (opts.provider) params.append('provider', opts.provider); - if (opts.projectId) params.append('projectId', opts.projectId); - if (opts.projectPath) params.append('projectPath', opts.projectPath); const limit = opts.limit ?? 20; params.append('limit', String(limit)); params.append('offset', String(slot.offset)); const qs = params.toString(); - const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; try { const response = await authenticatedFetch(url); @@ -305,14 +295,11 @@ export function useSessionStore() { }, [getSlot, notify]); /** - * Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated). - * - * Uses the DB-assigned `projectId`; the legacy folder-derived projectName - * is no longer accepted here. + * Re-fetch serverMessages from the provider sessions endpoint. */ const refreshFromServer = useCallback(async ( sessionId: string, - opts: { + _opts: { provider?: LLMProvider; projectId?: string; projectPath?: string; @@ -321,12 +308,9 @@ export function useSessionStore() { const slot = getSlot(sessionId); try { const params = new URLSearchParams(); - if (opts.provider) params.append('provider', opts.provider); - if (opts.projectId) params.append('projectId', opts.projectId); - if (opts.projectPath) params.append('projectPath', opts.projectPath); const qs = params.toString(); - const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; const response = await authenticatedFetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); diff --git a/src/utils/api.js b/src/utils/api.js index bd04f26f..439b1df0 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -56,20 +56,16 @@ export const api = { projects: () => authenticatedFetch('/api/projects'), projectTaskmaster: (projectId) => authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/taskmaster`), - // Unified endpoint — all providers through one URL. The legacy `projectName` - // query parameter is preserved on the wire (routes/messages.js still reads - // it) but it now carries a projectId value supplied by the caller. - unifiedSessionMessages: (sessionId, provider = 'claude', { projectId = '', projectPath = '', limit = null, offset = 0 } = {}) => { + // Unified endpoint for persisted session messages. + // Provider/project metadata are resolved by the backend from sessionId. + unifiedSessionMessages: (sessionId, _provider = 'claude', { limit = null, offset = 0 } = {}) => { const params = new URLSearchParams(); - params.append('provider', provider); - if (projectId) params.append('projectId', projectId); - if (projectPath) params.append('projectPath', projectPath); if (limit !== null) { params.append('limit', String(limit)); params.append('offset', String(offset)); } const queryString = params.toString(); - return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`); + return authenticatedFetch(`/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`); }, renameProject: (projectId, displayName) => authenticatedFetch(`/api/projects/${projectId}/rename`, {