mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 22:18:19 +00:00
Users previously had an all-or-nothing choice for completed sessions: either keep them in the active sidebar or permanently delete them. That made long-lived usage brittle because valuable history stayed in the way unless users destroyed it. This change introduces archiving as a first-class lifecycle so completed work can be hidden without losing transcript history, workspace context, or restoreability. The backend now persists session archive state and excludes archived rows from active session queries by default. Dedicated archive queries and routes make archived sessions and archived workspaces addressable on their own, which is necessary once hidden data can no longer be rebuilt from the active project list. Hard-delete behavior still cleans up transcript files so destructive deletes remain truly destructive. The frontend now mirrors that lifecycle in the sidebar. Delete flows distinguish between archive and permanent delete, archived sessions can be restored, archived workspaces appear beside standalone archived sessions, and archived project sessions open with the correct workspace context instead of routing to a session URL that leaves the main view empty. Follow-up archive UI polish keeps the status affordances explicit without competing with workspace names.
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import express, { type Request, type Response } from 'express';
|
|
|
|
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
|
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
|
import { sessionConversationsSearchService } from '@/modules/providers/services/session-conversations-search.service.js';
|
|
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
|
|
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
|
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
|
|
|
const router = express.Router();
|
|
|
|
const readPathParam = (value: unknown, name: string): string => {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value) && typeof value[0] === 'string') {
|
|
return value[0];
|
|
}
|
|
|
|
throw new AppError(`${name} path parameter is invalid.`, {
|
|
code: 'INVALID_PATH_PARAMETER',
|
|
statusCode: 400,
|
|
});
|
|
};
|
|
|
|
const normalizeProviderParam = (value: unknown): string =>
|
|
readPathParam(value, 'provider').trim().toLowerCase();
|
|
|
|
const SESSION_ID_PATTERN = /^[a-zA-Z0-9._-]{1,120}$/;
|
|
|
|
const parseSessionId = (value: unknown): string => {
|
|
const sessionId = readPathParam(value, 'sessionId').trim();
|
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
throw new AppError('Invalid sessionId.', {
|
|
code: 'INVALID_SESSION_ID',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
return sessionId;
|
|
};
|
|
|
|
const readOptionalQueryString = (value: unknown): string | undefined => {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = value.trim();
|
|
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;
|
|
}
|
|
|
|
const normalized = readOptionalQueryString(value);
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
|
|
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
|
|
return normalized;
|
|
}
|
|
|
|
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
|
|
code: 'INVALID_MCP_SCOPE',
|
|
statusCode: 400,
|
|
});
|
|
};
|
|
|
|
const parseMcpTransport = (value: unknown): McpTransport => {
|
|
const normalized = readOptionalQueryString(value);
|
|
if (!normalized) {
|
|
throw new AppError('transport is required.', {
|
|
code: 'MCP_TRANSPORT_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
|
|
return normalized;
|
|
}
|
|
|
|
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
|
|
code: 'INVALID_MCP_TRANSPORT',
|
|
statusCode: 400,
|
|
});
|
|
};
|
|
|
|
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
throw new AppError('Request body must be an object.', {
|
|
code: 'INVALID_REQUEST_BODY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const body = payload as Record<string, unknown>;
|
|
const name = readOptionalQueryString(body.name);
|
|
if (!name) {
|
|
throw new AppError('name is required.', {
|
|
code: 'MCP_NAME_REQUIRED',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const transport = parseMcpTransport(body.transport);
|
|
const scope = parseMcpScope(body.scope);
|
|
const workspacePath = readOptionalQueryString(body.workspacePath);
|
|
|
|
return {
|
|
name,
|
|
transport,
|
|
scope,
|
|
workspacePath,
|
|
command: readOptionalQueryString(body.command),
|
|
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
|
|
env: typeof body.env === 'object' && body.env !== null
|
|
? Object.fromEntries(
|
|
Object.entries(body.env as Record<string, unknown>).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
),
|
|
)
|
|
: undefined,
|
|
cwd: readOptionalQueryString(body.cwd),
|
|
url: readOptionalQueryString(body.url),
|
|
headers: typeof body.headers === 'object' && body.headers !== null
|
|
? Object.fromEntries(
|
|
Object.entries(body.headers as Record<string, unknown>).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
),
|
|
)
|
|
: undefined,
|
|
envVars: Array.isArray(body.envVars)
|
|
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
|
|
: undefined,
|
|
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
|
|
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
|
|
? Object.fromEntries(
|
|
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
),
|
|
)
|
|
: undefined,
|
|
};
|
|
};
|
|
|
|
const parseProvider = (value: unknown): LLMProvider => {
|
|
const normalized = normalizeProviderParam(value);
|
|
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
|
return normalized;
|
|
}
|
|
|
|
throw new AppError(`Unsupported provider "${normalized}".`, {
|
|
code: 'UNSUPPORTED_PROVIDER',
|
|
statusCode: 400,
|
|
});
|
|
};
|
|
|
|
const parseSessionRenameSummary = (payload: unknown): string => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
throw new AppError('Request body must be an object.', {
|
|
code: 'INVALID_REQUEST_BODY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const body = payload as Record<string, unknown>;
|
|
const summary = typeof body.summary === 'string' ? body.summary.trim() : '';
|
|
if (!summary) {
|
|
throw new AppError('Summary is required.', {
|
|
code: 'INVALID_SESSION_SUMMARY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
if (summary.length > 500) {
|
|
throw new AppError('Summary must not exceed 500 characters.', {
|
|
code: 'INVALID_SESSION_SUMMARY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
return summary;
|
|
};
|
|
|
|
const parseSessionSearchQuery = (value: unknown): string => {
|
|
const query = readOptionalQueryString(value) ?? '';
|
|
if (query.length < 2) {
|
|
throw new AppError('Query must be at least 2 characters', {
|
|
code: 'INVALID_SEARCH_QUERY',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
return query;
|
|
};
|
|
|
|
const parseSessionSearchLimit = (value: unknown): number => {
|
|
const raw = readOptionalQueryString(value);
|
|
if (!raw) {
|
|
return 50;
|
|
}
|
|
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (Number.isNaN(parsed)) {
|
|
throw new AppError('limit must be a valid integer.', {
|
|
code: 'INVALID_QUERY_PARAMETER',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
return Math.max(1, Math.min(parsed, 100));
|
|
};
|
|
|
|
router.get(
|
|
'/:provider/auth/status',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const provider = parseProvider(req.params.provider);
|
|
const status = await providerAuthService.getProviderAuthStatus(provider);
|
|
res.json(createApiSuccessResponse(status));
|
|
}),
|
|
);
|
|
|
|
// ----------------- MCP routes -----------------
|
|
router.get(
|
|
'/:provider/mcp/servers',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const provider = parseProvider(req.params.provider);
|
|
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
|
const scope = parseMcpScope(req.query.scope);
|
|
|
|
if (scope) {
|
|
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
|
|
res.json(createApiSuccessResponse({ provider, scope, servers }));
|
|
return;
|
|
}
|
|
|
|
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
|
|
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/:provider/mcp/servers',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const provider = parseProvider(req.params.provider);
|
|
const payload = parseMcpUpsertPayload(req.body);
|
|
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
|
|
res.status(201).json(createApiSuccessResponse({ server }));
|
|
}),
|
|
);
|
|
|
|
router.delete(
|
|
'/:provider/mcp/servers/:name',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const provider = parseProvider(req.params.provider);
|
|
const scope = parseMcpScope(req.query.scope);
|
|
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
|
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
|
name: readPathParam(req.params.name, 'name'),
|
|
scope,
|
|
workspacePath,
|
|
});
|
|
res.json(createApiSuccessResponse(result));
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/mcp/servers/global',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const payload = parseMcpUpsertPayload(req.body);
|
|
if (payload.scope === 'local') {
|
|
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
|
|
code: 'INVALID_GLOBAL_MCP_SCOPE',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
|
|
const results = await providerMcpService.addMcpServerToAllProviders({
|
|
...payload,
|
|
scope: payload.scope === 'user' ? 'user' : 'project',
|
|
});
|
|
res.status(201).json(createApiSuccessResponse({ results }));
|
|
}),
|
|
);
|
|
|
|
// ----------------- Session routes -----------------
|
|
router.get(
|
|
'/sessions/archived',
|
|
asyncHandler(async (_req: Request, res: Response) => {
|
|
const sessions = sessionsService.listArchivedSessions();
|
|
res.json(createApiSuccessResponse({ sessions }));
|
|
}),
|
|
);
|
|
|
|
router.delete(
|
|
'/sessions/:sessionId',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const sessionId = parseSessionId(req.params.sessionId);
|
|
const force = parseOptionalBooleanQuery(req.query.force, 'force') ?? false;
|
|
const deletedFromDisk = parseOptionalBooleanQuery(req.query.deletedFromDisk, 'deletedFromDisk') ?? force;
|
|
const result = await sessionsService.deleteOrArchiveSessionById(sessionId, {
|
|
force,
|
|
deletedFromDisk,
|
|
});
|
|
res.json(createApiSuccessResponse(result));
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/sessions/:sessionId/restore',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const sessionId = parseSessionId(req.params.sessionId);
|
|
const result = sessionsService.restoreSessionById(sessionId);
|
|
res.json(createApiSuccessResponse(result));
|
|
}),
|
|
);
|
|
|
|
router.put(
|
|
'/sessions/:sessionId',
|
|
asyncHandler(async (req: Request, res: Response) => {
|
|
const sessionId = parseSessionId(req.params.sessionId);
|
|
const summary = parseSessionRenameSummary(req.body);
|
|
const result = sessionsService.renameSessionById(sessionId, summary);
|
|
res.json(createApiSuccessResponse(result));
|
|
}),
|
|
);
|
|
|
|
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);
|
|
|
|
let limit: number | null = null;
|
|
if (limitRaw !== undefined) {
|
|
const parsedLimit = Number.parseInt(limitRaw, 10);
|
|
if (Number.isNaN(parsedLimit) || parsedLimit < 0) {
|
|
throw new AppError('limit must be a non-negative integer.', {
|
|
code: 'INVALID_QUERY_PARAMETER',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
limit = parsedLimit;
|
|
}
|
|
|
|
let offset = 0;
|
|
if (offsetRaw !== undefined) {
|
|
const parsedOffset = Number.parseInt(offsetRaw, 10);
|
|
if (Number.isNaN(parsedOffset) || parsedOffset < 0) {
|
|
throw new AppError('offset must be a non-negative integer.', {
|
|
code: 'INVALID_QUERY_PARAMETER',
|
|
statusCode: 400,
|
|
});
|
|
}
|
|
offset = parsedOffset;
|
|
}
|
|
|
|
const result = await sessionsService.fetchHistory(sessionId, {
|
|
limit,
|
|
offset,
|
|
});
|
|
res.json(result);
|
|
}),
|
|
);
|
|
|
|
router.get('/search/sessions', asyncHandler(async (req: Request, res: Response) => {
|
|
const query = parseSessionSearchQuery(req.query.q);
|
|
const limit = parseSessionSearchLimit(req.query.limit);
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
|
|
let closed = false;
|
|
const abortController = new AbortController();
|
|
req.on('close', () => {
|
|
closed = true;
|
|
abortController.abort();
|
|
});
|
|
|
|
try {
|
|
await sessionConversationsSearchService.search({
|
|
query,
|
|
limit,
|
|
signal: abortController.signal,
|
|
onProgress: ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
|
|
if (projectResult) {
|
|
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
|
return;
|
|
}
|
|
|
|
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
|
},
|
|
});
|
|
|
|
if (!closed) {
|
|
res.write('event: done\ndata: {}\n\n');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error searching conversations:', error);
|
|
if (!closed) {
|
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
|
}
|
|
} finally {
|
|
if (!closed) {
|
|
res.end();
|
|
}
|
|
}
|
|
}));
|
|
|
|
export default router;
|