- Updated session and subagent route handlers to support an optional `bypassCache` query parameter, allowing clients to bypass cached responses. - Enhanced README to include a Discord link for community engagement. - Improved CSS for lightbox toolbar buttons to address macOS hit-testing issues. - Refactored task ID linkification in markdown to ensure accurate matching and improved functionality in various components.
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
/**
|
|
* HTTP route handlers for Session Operations.
|
|
*
|
|
* Routes:
|
|
* - GET /api/projects/:projectId/sessions - List sessions
|
|
* - GET /api/projects/:projectId/sessions-paginated - Paginated sessions
|
|
* - GET /api/projects/:projectId/sessions/:sessionId - Full session detail
|
|
* - GET /api/projects/:projectId/sessions/:sessionId/groups - Conversation groups
|
|
* - GET /api/projects/:projectId/sessions/:sessionId/metrics - Session metrics
|
|
* - GET /api/projects/:projectId/sessions/:sessionId/waterfall - Waterfall data
|
|
*/
|
|
|
|
import { createLogger } from '@shared/utils/logger';
|
|
|
|
import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards';
|
|
import { DataCache } from '../services';
|
|
|
|
import type { SessionsByIdsOptions, SessionsPaginationOptions } from '../types';
|
|
import type { HttpServices } from './index';
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
const logger = createLogger('HTTP:sessions');
|
|
|
|
export function registerSessionRoutes(app: FastifyInstance, services: HttpServices): void {
|
|
// List sessions
|
|
app.get<{ Params: { projectId: string } }>(
|
|
'/api/projects/:projectId/sessions',
|
|
async (request) => {
|
|
try {
|
|
const validated = validateProjectId(request.params.projectId);
|
|
if (!validated.valid) {
|
|
logger.error(`GET sessions rejected: ${validated.error ?? 'unknown'}`);
|
|
return [];
|
|
}
|
|
|
|
const sessions = await services.projectScanner.listSessions(validated.value!);
|
|
return sessions;
|
|
} catch (error) {
|
|
logger.error(`Error in GET sessions for ${request.params.projectId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
|
|
// Paginated sessions
|
|
app.get<{
|
|
Params: { projectId: string };
|
|
Querystring: {
|
|
cursor?: string;
|
|
limit?: string;
|
|
includeTotalCount?: string;
|
|
prefilterAll?: string;
|
|
metadataLevel?: 'light' | 'deep';
|
|
};
|
|
}>('/api/projects/:projectId/sessions-paginated', async (request) => {
|
|
try {
|
|
const validated = validateProjectId(request.params.projectId);
|
|
if (!validated.valid) {
|
|
logger.error(`GET sessions-paginated rejected: ${validated.error ?? 'unknown'}`);
|
|
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
|
}
|
|
|
|
const cursor = request.query.cursor || null;
|
|
const limit = coercePageLimit(
|
|
request.query.limit ? Number(request.query.limit) : undefined,
|
|
20
|
|
);
|
|
const options: SessionsPaginationOptions = {
|
|
includeTotalCount: request.query.includeTotalCount !== 'false',
|
|
prefilterAll: request.query.prefilterAll !== 'false',
|
|
metadataLevel: request.query.metadataLevel,
|
|
};
|
|
|
|
const result = await services.projectScanner.listSessionsPaginated(
|
|
validated.value!,
|
|
cursor,
|
|
limit,
|
|
options
|
|
);
|
|
return result;
|
|
} catch (error) {
|
|
logger.error(`Error in GET sessions-paginated for ${request.params.projectId}:`, error);
|
|
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
|
}
|
|
});
|
|
|
|
// Fetch sessions by IDs (for pinned sessions beyond paginated page)
|
|
app.post<{ Params: { projectId: string } }>(
|
|
'/api/projects/:projectId/sessions-by-ids',
|
|
async (request) => {
|
|
try {
|
|
const validated = validateProjectId(request.params.projectId);
|
|
if (!validated.valid) {
|
|
logger.error(`POST sessions-by-ids rejected: ${validated.error ?? 'unknown'}`);
|
|
return [];
|
|
}
|
|
|
|
const { sessionIds } = request.body as { sessionIds?: string[] };
|
|
if (!Array.isArray(sessionIds)) {
|
|
logger.error('POST sessions-by-ids rejected: sessionIds must be an array');
|
|
return [];
|
|
}
|
|
const { metadataLevel } = request.body as SessionsByIdsOptions;
|
|
|
|
// Cap at 50 IDs
|
|
const capped = sessionIds.slice(0, 50);
|
|
|
|
// Validate each session ID
|
|
const validIds: string[] = [];
|
|
for (const id of capped) {
|
|
const result = validateSessionId(id);
|
|
if (result.valid) {
|
|
validIds.push(result.value!);
|
|
}
|
|
}
|
|
|
|
if (validIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const fsType = services.projectScanner.getFileSystemProvider().type;
|
|
const effectiveMetadataLevel = metadataLevel ?? (fsType === 'ssh' ? 'light' : 'deep');
|
|
const results = await Promise.all(
|
|
validIds.map((id) =>
|
|
services.projectScanner.getSessionWithOptions(validated.value!, id, {
|
|
metadataLevel: effectiveMetadataLevel,
|
|
})
|
|
)
|
|
);
|
|
|
|
return results.filter((s): s is NonNullable<typeof s> => s !== null);
|
|
} catch (error) {
|
|
logger.error(`Error in POST sessions-by-ids for ${request.params.projectId}:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
|
|
// Session detail
|
|
app.get<{
|
|
Params: { projectId: string; sessionId: string };
|
|
Querystring: { bypassCache?: string };
|
|
}>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
|
|
try {
|
|
const validatedProject = validateProjectId(request.params.projectId);
|
|
const validatedSession = validateSessionId(request.params.sessionId);
|
|
if (!validatedProject.valid || !validatedSession.valid) {
|
|
logger.error(
|
|
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const safeProjectId = validatedProject.value!;
|
|
const safeSessionId = validatedSession.value!;
|
|
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
|
const bypassCache = request.query?.bypassCache === 'true';
|
|
|
|
// Check cache first
|
|
let sessionDetail = services.dataCache.get(cacheKey);
|
|
if (sessionDetail && !bypassCache) {
|
|
return sessionDetail;
|
|
}
|
|
|
|
const fsType = services.projectScanner.getFileSystemProvider().type;
|
|
// In SSH mode, avoid an extra deep metadata scan before full parse.
|
|
const session = await services.projectScanner.getSessionWithOptions(
|
|
safeProjectId,
|
|
safeSessionId,
|
|
{
|
|
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
|
|
}
|
|
);
|
|
if (!session) {
|
|
logger.error(`Session not found: ${safeSessionId}`);
|
|
return null;
|
|
}
|
|
|
|
// Parse session messages
|
|
const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId);
|
|
|
|
// Resolve subagents
|
|
const subagents = await services.subagentResolver.resolveSubagents(
|
|
safeProjectId,
|
|
safeSessionId,
|
|
parsedSession.taskCalls,
|
|
parsedSession.messages
|
|
);
|
|
session.hasSubagents = subagents.length > 0;
|
|
|
|
// Build session detail with chunks
|
|
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
|
session,
|
|
parsedSession.messages,
|
|
subagents
|
|
);
|
|
|
|
// Cache the result
|
|
services.dataCache.set(cacheKey, sessionDetail);
|
|
|
|
return sessionDetail;
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// Conversation groups
|
|
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
|
'/api/projects/:projectId/sessions/:sessionId/groups',
|
|
async (request) => {
|
|
try {
|
|
const validatedProject = validateProjectId(request.params.projectId);
|
|
const validatedSession = validateSessionId(request.params.sessionId);
|
|
if (!validatedProject.valid || !validatedSession.valid) {
|
|
logger.error(
|
|
`GET session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
|
);
|
|
return [];
|
|
}
|
|
|
|
const safeProjectId = validatedProject.value!;
|
|
const safeSessionId = validatedSession.value!;
|
|
|
|
const parsedSession = await services.sessionParser.parseSession(
|
|
safeProjectId,
|
|
safeSessionId
|
|
);
|
|
|
|
const subagents = await services.subagentResolver.resolveSubagents(
|
|
safeProjectId,
|
|
safeSessionId,
|
|
parsedSession.taskCalls,
|
|
parsedSession.messages
|
|
);
|
|
|
|
const groups = services.chunkBuilder.buildGroups(parsedSession.messages, subagents);
|
|
return groups;
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in GET session-groups for ${request.params.projectId}/${request.params.sessionId}:`,
|
|
error
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
|
|
// Session metrics
|
|
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
|
'/api/projects/:projectId/sessions/:sessionId/metrics',
|
|
async (request) => {
|
|
try {
|
|
const validatedProject = validateProjectId(request.params.projectId);
|
|
const validatedSession = validateSessionId(request.params.sessionId);
|
|
if (!validatedProject.valid || !validatedSession.valid) {
|
|
return null;
|
|
}
|
|
|
|
const safeProjectId = validatedProject.value!;
|
|
const safeSessionId = validatedSession.value!;
|
|
|
|
// Try cache first
|
|
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
|
const cached = services.dataCache.get(cacheKey);
|
|
if (cached) {
|
|
return cached.metrics;
|
|
}
|
|
|
|
const parsedSession = await services.sessionParser.parseSession(
|
|
safeProjectId,
|
|
safeSessionId
|
|
);
|
|
return parsedSession.metrics;
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in GET session-metrics for ${request.params.projectId}/${request.params.sessionId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Waterfall data
|
|
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
|
'/api/projects/:projectId/sessions/:sessionId/waterfall',
|
|
async (request) => {
|
|
try {
|
|
const validatedProject = validateProjectId(request.params.projectId);
|
|
const validatedSession = validateSessionId(request.params.sessionId);
|
|
if (!validatedProject.valid || !validatedSession.valid) {
|
|
return null;
|
|
}
|
|
|
|
const safeProjectId = validatedProject.value!;
|
|
const safeSessionId = validatedSession.value!;
|
|
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
|
|
|
// Try cache first for session detail
|
|
let detail = services.dataCache.get(cacheKey);
|
|
|
|
if (!detail) {
|
|
const session = await services.projectScanner.getSession(safeProjectId, safeSessionId);
|
|
if (!session) return null;
|
|
|
|
const parsedSession = await services.sessionParser.parseSession(
|
|
safeProjectId,
|
|
safeSessionId
|
|
);
|
|
const subagents = await services.subagentResolver.resolveSubagents(
|
|
safeProjectId,
|
|
safeSessionId,
|
|
parsedSession.taskCalls,
|
|
parsedSession.messages
|
|
);
|
|
|
|
detail = services.chunkBuilder.buildSessionDetail(
|
|
session,
|
|
parsedSession.messages,
|
|
subagents
|
|
);
|
|
services.dataCache.set(cacheKey, detail);
|
|
}
|
|
|
|
return services.chunkBuilder.buildWaterfallData(detail.chunks, detail.processes);
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error in GET waterfall for ${request.params.projectId}/${request.params.sessionId}:`,
|
|
error
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
);
|
|
}
|