fix: on file update, no directory rescan is needed

This commit is contained in:
Haileyesus
2026-04-06 22:18:08 +03:00
parent 28aa5a3902
commit f576b8e6d2
17 changed files with 372 additions and 126 deletions

View File

@@ -6,7 +6,7 @@ import os from 'os';
import TOML from '@iarna/toml';
import { getCodexSessions } from '../../../projects.js';
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
import { llmSessionsService } from '@/modules/llm/sessions.service.js';
import { llmSessionsService } from '@/modules/llm/services/sessions.service.js';
const router = express.Router();

View File

@@ -1,6 +1,6 @@
import express from 'express';
import sessionManager from '../../../sessionManager.js';
import { llmSessionsService } from '@/modules/llm/sessions.service.js';
import { llmSessionsService } from '@/modules/llm/services/sessions.service.js';
const router = express.Router();

View File

@@ -53,11 +53,15 @@ async function onUpdate(
filePath: string,
provider: LLMProvider,
): Promise<void> {
if (!isWatcherTargetFile(provider, filePath)) {
return;
}
try {
const result = await llmSessionsService.synchronizeProvider(provider, { fullRescan: true });
const result = await llmSessionsService.synchronizeProviderFile(provider, filePath);
logger.info(`LLM watcher sync complete for provider "${provider}" after ${eventType}`, {
filePath,
processed: result.processed,
indexed: result.indexed,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -69,6 +73,17 @@ async function onUpdate(
}
}
/**
* Filters watcher events to provider-specific transcript artifact file types.
*/
function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
if (provider === 'gemini') {
return filePath.endsWith('.json');
}
return filePath.endsWith('.jsonl');
}
/**
* Initializes LLM session watchers and performs an initial index sync.
*/

View File

@@ -145,12 +145,12 @@ export const llmSessionsService = {
},
/**
* Runs one provider indexer and updates `scan_state.last_scanned_at`.
* Indexes one provider artifact file without running a full provider rescan.
*/
async synchronizeProvider(
async synchronizeProviderFile(
provider: LLMProvider,
options: { fullRescan?: boolean } = {},
): Promise<{ provider: LLMProvider; processed: number }> {
filePath: string,
): Promise<{ provider: LLMProvider; indexed: boolean }> {
const indexer = sessionIndexers.find((entry) => entry.provider === provider);
if (!indexer) {
throw new AppError(`No session indexer registered for provider "${provider}".`, {
@@ -159,11 +159,12 @@ export const llmSessionsService = {
});
}
const lastScanAt = options.fullRescan ? null : scanStateDb.getLastScannedAt();
const processed = await indexer.synchronize(lastScanAt);
scanStateDb.updateLastScannedAt();
if (!indexer.synchronizeFile) {
return { provider, indexed: false };
}
return { provider, processed };
const indexed = await indexer.synchronizeFile(filePath);
return { provider, indexed };
},
updateSessionCustomName(sessionId: string, sessionCustomName: string): void {

View File

@@ -22,15 +22,15 @@ type ParsedSession = {
*/
export class ClaudeSessionIndexer implements ISessionIndexer {
readonly provider = 'claude' as const;
private readonly claudeHome = path.join(os.homedir(), '.claude');
/**
* Scans ~/.claude projects and upserts discovered sessions into DB.
*/
async synchronize(lastScanAt: Date | null): Promise<number> {
const claudeHome = path.join(os.homedir(), '.claude');
const nameMap = await buildLookupMap(path.join(claudeHome, 'history.jsonl'), 'sessionId', 'display');
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const files = await findFilesRecursivelyCreatedAfter(
path.join(claudeHome, 'projects'),
path.join(this.claudeHome, 'projects'),
'.jsonl',
lastScanAt,
);
@@ -58,6 +58,34 @@ export class ClaudeSessionIndexer implements ISessionIndexer {
return processed;
}
/**
* Parses and upserts one Claude session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<boolean> {
if (!filePath.endsWith('.jsonl')) {
return false;
}
const nameMap = await buildLookupMap(path.join(this.claudeHome, 'history.jsonl'), 'sessionId', 'display');
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
return false;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.workspacePath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath,
);
return true;
}
/**
* Extracts session metadata from one Claude JSONL session file.
*/

View File

@@ -22,15 +22,15 @@ type ParsedSession = {
*/
export class CodexSessionIndexer implements ISessionIndexer {
readonly provider = 'codex' as const;
private readonly codexHome = path.join(os.homedir(), '.codex');
/**
* Scans ~/.codex sessions and upserts discovered sessions into DB.
*/
async synchronize(lastScanAt: Date | null): Promise<number> {
const codexHome = path.join(os.homedir(), '.codex');
const nameMap = await buildLookupMap(path.join(codexHome, 'session_index.jsonl'), 'id', 'thread_name');
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
const files = await findFilesRecursivelyCreatedAfter(
path.join(codexHome, 'sessions'),
path.join(this.codexHome, 'sessions'),
'.jsonl',
lastScanAt,
);
@@ -58,6 +58,34 @@ export class CodexSessionIndexer implements ISessionIndexer {
return processed;
}
/**
* Parses and upserts one Codex session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<boolean> {
if (!filePath.endsWith('.jsonl')) {
return false;
}
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
const parsed = await this.processSessionFile(filePath, nameMap);
if (!parsed) {
return false;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.workspacePath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath,
);
return true;
}
/**
* Extracts session metadata from one Codex JSONL session file.
*/

View File

@@ -25,13 +25,13 @@ type ParsedSession = {
*/
export class CursorSessionIndexer implements ISessionIndexer {
readonly provider = 'cursor' as const;
private readonly cursorHome = path.join(os.homedir(), '.cursor');
/**
* Scans Cursor chats and upserts discovered sessions into DB.
*/
async synchronize(lastScanAt: Date | null): Promise<number> {
const cursorHome = path.join(os.homedir(), '.cursor');
const projectsDir = path.join(cursorHome, 'projects');
const projectsDir = path.join(this.cursorHome, 'projects');
const projectEntries = await listDirectoryEntriesSafe(projectsDir);
const seenWorkspacePaths = new Set<string>();
@@ -49,7 +49,7 @@ export class CursorSessionIndexer implements ISessionIndexer {
seenWorkspacePaths.add(workspacePath);
const workspaceHash = this.md5(workspacePath);
const chatsDir = path.join(cursorHome, 'chats', workspaceHash);
const chatsDir = path.join(this.cursorHome, 'chats', workspaceHash);
const files = await findFilesRecursivelyCreatedAfter(chatsDir, '.jsonl', lastScanAt);
for (const filePath of files) {
@@ -75,6 +75,33 @@ export class CursorSessionIndexer implements ISessionIndexer {
return processed;
}
/**
* Parses and upserts one Cursor session JSONL file.
*/
async synchronizeFile(filePath: string): Promise<boolean> {
if (!filePath.endsWith('.jsonl')) {
return false;
}
const parsed = await this.processSessionFile(filePath);
if (!parsed) {
return false;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.workspacePath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath,
);
return true;
}
/**
* Produces the same workspace hash Cursor uses in chat directory names.
*/

View File

@@ -21,19 +21,19 @@ type ParsedSession = {
*/
export class GeminiSessionIndexer implements ISessionIndexer {
readonly provider = 'gemini' as const;
private readonly geminiHome = path.join(os.homedir(), '.gemini');
/**
* Scans Gemini session JSON files and upserts discovered sessions into DB.
*/
async synchronize(lastScanAt: Date | null): Promise<number> {
const geminiHome = path.join(os.homedir(), '.gemini');
const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
path.join(geminiHome, 'sessions'),
path.join(this.geminiHome, 'sessions'),
'.json',
lastScanAt,
);
const tempFiles = await findFilesRecursivelyCreatedAfter(
path.join(geminiHome, 'tmp'),
path.join(this.geminiHome, 'tmp'),
'.json',
lastScanAt,
);
@@ -42,7 +42,7 @@ export class GeminiSessionIndexer implements ISessionIndexer {
let processed = 0;
for (const filePath of files) {
if (
filePath.startsWith(path.join(geminiHome, 'tmp')) &&
filePath.startsWith(path.join(this.geminiHome, 'tmp')) &&
!filePath.includes(`${path.sep}chats${path.sep}`)
) {
continue;
@@ -69,6 +69,40 @@ export class GeminiSessionIndexer implements ISessionIndexer {
return processed;
}
/**
* Parses and upserts one Gemini session JSON file.
*/
async synchronizeFile(filePath: string): Promise<boolean> {
if (!filePath.endsWith('.json')) {
return false;
}
if (
filePath.startsWith(path.join(this.geminiHome, 'tmp')) &&
!filePath.includes(`${path.sep}chats${path.sep}`)
) {
return false;
}
const parsed = await this.processSessionFile(filePath);
if (!parsed) {
return false;
}
const timestamps = await readFileTimestamps(filePath);
sessionsDb.createSession(
parsed.sessionId,
this.provider,
parsed.workspacePath,
parsed.sessionName,
timestamps.createdAt,
timestamps.updatedAt,
filePath,
);
return true;
}
/**
* Extracts session metadata from one Gemini JSON artifact.
*/

View File

@@ -10,4 +10,9 @@ export interface ISessionIndexer {
* Scans provider session artifacts and upserts discovered sessions into DB.
*/
synchronize(lastScanAt: Date | null): Promise<number>;
/**
* Parses and upserts one provider artifact file without running a full directory scan.
*/
synchronizeFile?(filePath: string): Promise<boolean>;
}

View File

@@ -74,6 +74,7 @@ export async function findFilesRecursivelyCreatedAfter(
fileList: string[] = [],
): Promise<string[]> {
try {
console.log("HEY THERE!")
const entries = await fsp.readdir(rootDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);

View File

@@ -66,36 +66,32 @@ test('llmSessionsService.synchronizeSessions aggregates processed counts and fai
}
});
// This test covers provider-specific sync behavior for both incremental and full-rescan modes.
test('llmSessionsService.synchronizeProvider honors fullRescan option', { concurrency: false }, async () => {
const observedScanDates: Array<Date | null> = [];
const restoreScanDate = patchMethod(scanStateDb, 'getLastScannedAt', () => new Date('2026-04-02T00:00:00.000Z'));
const restoreUpdateScanDate = patchMethod(scanStateDb, 'updateLastScannedAt', () => {});
// This test covers single-file indexing delegation used by the watcher (no full provider rescan).
test('llmSessionsService.synchronizeProviderFile delegates to provider indexer file sync', { concurrency: false }, async () => {
let synchronizeCalls = 0;
let synchronizeFilePath: string | null = null;
const restoreIndexers = patchIndexers([
{
provider: 'cursor',
async synchronize(lastScanAt) {
observedScanDates.push(lastScanAt);
return 7;
provider: 'claude',
async synchronize() {
synchronizeCalls += 1;
return 0;
},
async synchronizeFile(filePath: string) {
synchronizeFilePath = filePath;
return true;
},
},
]);
try {
const incremental = await llmSessionsService.synchronizeProvider('cursor');
const fullRescan = await llmSessionsService.synchronizeProvider('cursor', { fullRescan: true });
assert.equal(incremental.provider, 'cursor');
assert.equal(incremental.processed, 7);
assert.equal(fullRescan.provider, 'cursor');
assert.equal(fullRescan.processed, 7);
assert.equal(observedScanDates.length, 2);
assert.ok(observedScanDates[0] instanceof Date);
assert.equal(observedScanDates[1], null);
const result = await llmSessionsService.synchronizeProviderFile('claude', '/tmp/claude-session.jsonl');
assert.equal(result.provider, 'claude');
assert.equal(result.indexed, true);
assert.equal(synchronizeFilePath, '/tmp/claude-session.jsonl');
assert.equal(synchronizeCalls, 0);
} finally {
restoreIndexers();
restoreUpdateScanDate();
restoreScanDate();
}
});

View File

@@ -11,7 +11,7 @@ import {
} from '../../../projects.js';
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
import { llmSessionsService } from '@/modules/llm/sessions.service.js';
import { llmSessionsService } from '@/modules/llm/services/sessions.service.js';
import { authenticateToken } from '../auth/auth.middleware.js';
import { getWorkspaceNameFromPath, WORKSPACES_ROOT, validateWorkspacePath } from './projects.utils.js';

View File

@@ -4,13 +4,6 @@ import type {
ApiSuccessShape,
} from '@/shared/types/http.js';
export function createApiMeta(requestId?: string, startedAt?: string): ApiMeta {
return {
requestId,
startedAt,
};
}
export function createApiSuccessResponse<TData>(
data: TData,
meta?: ApiMeta

View File

@@ -1,30 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js';
import { getRequestContext } from '@/shared/http/request-context.js';
import { AppError } from '@/shared/utils/app-error.js';
import { logger } from '@/shared/utils/logger.js';
export function errorHandler(
error: Error,
req: Request,
res: Response,
_next: NextFunction
): void {
const appError = error instanceof AppError ? error : new AppError(error.message);
const context = getRequestContext(req);
const payload = createApiErrorResponse(
appError.code,
appError.message,
createApiMeta(context?.requestId, context?.startedAt),
appError.details
);
logger.error(appError.message, {
code: appError.code,
statusCode: appError.statusCode,
requestId: context?.requestId,
});
res.status(appError.statusCode).json(payload);
}

View File

@@ -1,15 +0,0 @@
import type { Request, Response } from 'express';
import { createApiErrorResponse, createApiMeta } from '@/shared/http/api-response.js';
import { getRequestContext } from '@/shared/http/request-context.js';
export function notFoundHandler(req: Request, res: Response): void {
const context = getRequestContext(req);
const payload = createApiErrorResponse(
'NOT_FOUND',
`Route not found: ${req.originalUrl}`,
createApiMeta(context?.requestId, context?.startedAt)
);
res.status(404).json(payload);
}

View File

@@ -1,27 +0,0 @@
import { randomUUID } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import type { RequestContext } from '@/shared/types/http.js';
type RequestWithContext = Request & {
context?: RequestContext;
};
export function getRequestContext(req: Request): RequestContext | undefined {
return (req as RequestWithContext).context;
}
// give every request a context with a unique ID and timestamp for tracking purposes
export function requestContextMiddleware(req: Request, res: Response, next: NextFunction): void {
const requestId = randomUUID();
const startedAt = new Date().toISOString();
const context: RequestContext = {
requestId,
startedAt,
};
(req as RequestWithContext).context = context;
(res.locals as Record<string, unknown>).requestId = requestId;
next();
}