feat(sessions): introduce metadata level for session retrieval

- Added a new `metadataLevel` option to the sessions pagination API, allowing clients to specify the depth of metadata returned (light or deep).
- Updated the `ProjectScanner` to handle session metadata based on the specified level, improving performance and flexibility in session data retrieval.
- Enhanced the HTTP client and session slice to support the new metadata level option, ensuring consistent behavior across the application.

This commit enhances session management by providing users with the ability to choose the level of detail in session metadata, optimizing performance for different use cases.
This commit is contained in:
matt 2026-02-12 18:02:48 +09:00
parent 2193b2ed8a
commit b42349fdba
6 changed files with 94 additions and 55 deletions

View file

@ -50,6 +50,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
limit?: string; limit?: string;
includeTotalCount?: string; includeTotalCount?: string;
prefilterAll?: string; prefilterAll?: string;
metadataLevel?: 'light' | 'deep';
}; };
}>('/api/projects/:projectId/sessions-paginated', async (request) => { }>('/api/projects/:projectId/sessions-paginated', async (request) => {
try { try {
@ -67,6 +68,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
const options: SessionsPaginationOptions = { const options: SessionsPaginationOptions = {
includeTotalCount: request.query.includeTotalCount !== 'false', includeTotalCount: request.query.includeTotalCount !== 'false',
prefilterAll: request.query.prefilterAll !== 'false', prefilterAll: request.query.prefilterAll !== 'false',
metadataLevel: request.query.metadataLevel,
}; };
const result = await services.projectScanner.listSessionsPaginated( const result = await services.projectScanner.listSessionsPaginated(

View file

@ -116,7 +116,11 @@ export class ProjectScanner {
); );
// Process each project directory (may return multiple projects per dir) // Process each project directory (may return multiple projects per dir)
const projectArrays = await Promise.all(projectDirs.map((dir) => this.scanProject(dir.name))); const projectArrays = await this.collectFulfilledInBatches(
projectDirs,
this.fsProvider.type === 'ssh' ? 8 : 24,
async (dir) => this.scanProject(dir.name)
);
// Flatten and sort by most recent // Flatten and sort by most recent
const validProjects = projectArrays.flat(); const validProjects = projectArrays.flat();
@ -381,6 +385,7 @@ export class ProjectScanner {
const projectPath = path.join(this.projectsDir, baseDir); const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId); const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const shouldFilterNoise = this.fsProvider.type !== 'ssh';
const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';
if (!(await this.fsProvider.exists(projectPath))) { if (!(await this.fsProvider.exists(projectPath))) {
return []; return [];
@ -411,28 +416,14 @@ export class ProjectScanner {
} }
} }
try { return this.buildSessionForListing(
return await this.buildSessionMetadata( metadataLevel,
projectId, projectId,
sessionId, sessionId,
filePath, filePath,
decodedPath, decodedPath,
prefetchedMtimeMs prefetchedMtimeMs
); );
} catch (error) {
if (this.fsProvider.type !== 'ssh') {
throw error;
}
logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error);
return this.buildLightSessionMetadata(
projectId,
sessionId,
filePath,
decodedPath,
prefetchedMtimeMs
);
}
}) })
); );
@ -472,6 +463,8 @@ export class ProjectScanner {
const projectPath = path.join(this.projectsDir, baseDir); const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId); const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
const shouldFilterNoise = this.fsProvider.type !== 'ssh'; const shouldFilterNoise = this.fsProvider.type !== 'ssh';
const metadataLevel: SessionMetadataLevel =
options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');
if (!(await this.fsProvider.exists(projectPath))) { if (!(await this.fsProvider.exists(projectPath))) {
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
@ -524,7 +517,7 @@ export class ProjectScanner {
// This is slower but provides exact totalCount. // This is slower but provides exact totalCount.
let validSessionIds: Set<string> | null = null; let validSessionIds: Set<string> | null = null;
let totalCount = 0; let totalCount = 0;
if (prefilterAll && shouldFilterNoise) { if (prefilterAll && shouldFilterNoise && metadataLevel === 'deep') {
const contentResults = await Promise.allSettled( const contentResults = await Promise.allSettled(
fileInfos.map(async (fileInfo) => ({ fileInfos.map(async (fileInfo) => ({
sessionId: fileInfo.sessionId, sessionId: fileInfo.sessionId,
@ -613,34 +606,16 @@ export class ProjectScanner {
const toBuild = withContent.slice(0, needed); const toBuild = withContent.slice(0, needed);
const builtSessions = await Promise.all( const builtSessions = await Promise.all(
toBuild.map(async ({ fileInfo }) => { toBuild.map(({ fileInfo }) =>
try { this.buildSessionForListing(
return await this.buildSessionMetadata( metadataLevel,
projectId, projectId,
fileInfo.sessionId, fileInfo.sessionId,
fileInfo.filePath, fileInfo.filePath,
decodedPath, decodedPath,
fileInfo.mtimeMs fileInfo.mtimeMs
); )
} catch (error) { )
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
if (this.fsProvider.type !== 'ssh') {
throw error;
}
logger.debug(
`SSH page metadata parse failed for ${fileInfo.sessionId}, using light fallback`,
error
);
return this.buildLightSessionMetadata(
projectId,
fileInfo.sessionId,
fileInfo.filePath,
decodedPath,
fileInfo.mtimeMs
);
}
})
); );
sessions.push(...builtSessions); sessions.push(...builtSessions);
@ -701,8 +676,7 @@ export class ProjectScanner {
projectPath: string, projectPath: string,
prefetchedMtimeMs?: number prefetchedMtimeMs?: number
): Promise<Session> { ): Promise<Session> {
const usePrefetchedTimes = const usePrefetchedTimes = typeof prefetchedMtimeMs === 'number';
this.fsProvider.type === 'ssh' && typeof prefetchedMtimeMs === 'number';
const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath); const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath);
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now(); const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime; const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
@ -766,6 +740,53 @@ export class ProjectScanner {
}; };
} }
/**
* Build session metadata according to requested listing depth.
* In SSH mode, deep parse failures degrade gracefully to light metadata.
*/
private async buildSessionForListing(
metadataLevel: SessionMetadataLevel,
projectId: string,
sessionId: string,
filePath: string,
projectPath: string,
prefetchedMtimeMs?: number
): Promise<Session> {
if (metadataLevel === 'light') {
return this.buildLightSessionMetadata(
projectId,
sessionId,
filePath,
projectPath,
prefetchedMtimeMs
);
}
try {
return await this.buildSessionMetadata(
projectId,
sessionId,
filePath,
projectPath,
prefetchedMtimeMs
);
} catch (error) {
// In SSH mode, never drop a visible session row due to transient deep-parse failures.
if (this.fsProvider.type !== 'ssh') {
throw error;
}
logger.debug(`SSH metadata parse failed for ${sessionId}, using light fallback`, error);
return this.buildLightSessionMetadata(
projectId,
sessionId,
filePath,
projectPath,
prefetchedMtimeMs
);
}
}
/** /**
* Gets a single session's metadata. * Gets a single session's metadata.
*/ */

View file

@ -71,7 +71,9 @@ export class WorktreeGrouper {
// 2. Filter sessions for each project to only include non-noise sessions // 2. Filter sessions for each project to only include non-noise sessions
const projectFilteredSessions = new Map<string, string[]>(); const projectFilteredSessions = new Map<string, string[]>();
const shouldFilterNoise = this.fsProvider.type !== 'ssh'; // Fast-first default for both local and SSH: avoid full-file scans during dashboard load.
// Can be re-enabled for strict parity debugging.
const shouldFilterNoise = process.env.CLAUDE_DEVTOOLS_STRICT_SESSION_FILTER === '1';
await Promise.all( await Promise.all(
projects.map(async (project) => { projects.map(async (project) => {
const baseDir = extractBaseDir(project.id); const baseDir = extractBaseDir(project.id);

View file

@ -284,4 +284,11 @@ export interface SessionsPaginationOptions {
* @default true * @default true
*/ */
prefilterAll?: boolean; prefilterAll?: boolean;
/**
* Metadata depth to return for listed sessions.
* - light: filesystem metadata only (fast)
* - deep: includes parsed session content summary fields (slower)
* @default 'deep'
*/
metadataLevel?: SessionMetadataLevel;
} }

View file

@ -190,6 +190,7 @@ export class HttpAPIClient implements ElectronAPI {
if (limit) params.set('limit', String(limit)); if (limit) params.set('limit', String(limit));
if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false'); if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false');
if (options?.prefilterAll === false) params.set('prefilterAll', 'false'); if (options?.prefilterAll === false) params.set('prefilterAll', 'false');
if (options?.metadataLevel) params.set('metadataLevel', options.metadataLevel);
const qs = params.toString(); const qs = params.toString();
const encodedId = encodeURIComponent(projectId); const encodedId = encodeURIComponent(projectId);
const path = `/api/projects/${encodedId}/sessions-paginated`; const path = `/api/projects/${encodedId}/sessions-paginated`;

View file

@ -95,9 +95,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
sessionsTotalCount: 0, sessionsTotalCount: 0,
}); });
try { try {
const { connectionMode } = get();
const result = await api.getSessionsPaginated(projectId, null, 20, { const result = await api.getSessionsPaginated(projectId, null, 20, {
includeTotalCount: false, includeTotalCount: false,
prefilterAll: false, prefilterAll: false,
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
}); });
set({ set({
sessions: result.sessions, sessions: result.sessions,
@ -129,9 +131,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
set({ sessionsLoadingMore: true }); set({ sessionsLoadingMore: true });
try { try {
const { connectionMode } = get();
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, { const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
includeTotalCount: false, includeTotalCount: false,
prefilterAll: false, prefilterAll: false,
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
}); });
set((prevState) => { set((prevState) => {
// Deduplicate: pinned sessions fetched earlier may appear in paginated results // Deduplicate: pinned sessions fetched earlier may appear in paginated results
@ -209,9 +213,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
projectRefreshGeneration.set(projectId, generation); projectRefreshGeneration.set(projectId, generation);
try { try {
const { connectionMode } = get();
const result = await api.getSessionsPaginated(projectId, null, 20, { const result = await api.getSessionsPaginated(projectId, null, 20, {
includeTotalCount: false, includeTotalCount: false,
prefilterAll: false, prefilterAll: false,
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
}); });
// Drop stale responses from older in-flight refreshes // Drop stale responses from older in-flight refreshes