refactor: move fetching messages to module

This commit is contained in:
Haileyesus
2026-04-27 14:30:09 +03:00
parent 9663f08fcb
commit 16954c883b
9 changed files with 116 additions and 136 deletions

View File

@@ -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);

View File

@@ -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<AnyRecord[]> {
}
async function getSessionMessages(
projectName: string,
sessionId: string,
limit: number | null,
offset: number,
): Promise<ClaudeHistoryMessagesResult> {
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<FetchHistoryResult> {
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);

View File

@@ -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;

View File

@@ -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<FetchHistoryOptions, 'limit' | 'offset'> = {},
): Promise<FetchHistoryResult> {
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 };
},
/**

View File

@@ -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`.)

View File

@@ -1,78 +0,0 @@
/**
* Unified messages endpoint.
*
* GET /api/sessions/:sessionId/messages?provider=claude&projectId=<id>&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/<folder>/,
// 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;

View File

@@ -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;