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:
parent
2193b2ed8a
commit
b42349fdba
6 changed files with 94 additions and 55 deletions
|
|
@ -50,6 +50,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
|||
limit?: string;
|
||||
includeTotalCount?: string;
|
||||
prefilterAll?: string;
|
||||
metadataLevel?: 'light' | 'deep';
|
||||
};
|
||||
}>('/api/projects/:projectId/sessions-paginated', async (request) => {
|
||||
try {
|
||||
|
|
@ -67,6 +68,7 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
|||
const options: SessionsPaginationOptions = {
|
||||
includeTotalCount: request.query.includeTotalCount !== 'false',
|
||||
prefilterAll: request.query.prefilterAll !== 'false',
|
||||
metadataLevel: request.query.metadataLevel,
|
||||
};
|
||||
|
||||
const result = await services.projectScanner.listSessionsPaginated(
|
||||
|
|
|
|||
|
|
@ -116,7 +116,11 @@ export class ProjectScanner {
|
|||
);
|
||||
|
||||
// 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
|
||||
const validProjects = projectArrays.flat();
|
||||
|
|
@ -381,6 +385,7 @@ export class ProjectScanner {
|
|||
const projectPath = path.join(this.projectsDir, baseDir);
|
||||
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
|
||||
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
||||
const metadataLevel: SessionMetadataLevel = this.fsProvider.type === 'ssh' ? 'light' : 'deep';
|
||||
|
||||
if (!(await this.fsProvider.exists(projectPath))) {
|
||||
return [];
|
||||
|
|
@ -411,28 +416,14 @@ export class ProjectScanner {
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.buildSessionMetadata(
|
||||
projectId,
|
||||
sessionId,
|
||||
filePath,
|
||||
decodedPath,
|
||||
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
|
||||
);
|
||||
}
|
||||
return this.buildSessionForListing(
|
||||
metadataLevel,
|
||||
projectId,
|
||||
sessionId,
|
||||
filePath,
|
||||
decodedPath,
|
||||
prefetchedMtimeMs
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -472,6 +463,8 @@ export class ProjectScanner {
|
|||
const projectPath = path.join(this.projectsDir, baseDir);
|
||||
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
|
||||
const shouldFilterNoise = this.fsProvider.type !== 'ssh';
|
||||
const metadataLevel: SessionMetadataLevel =
|
||||
options?.metadataLevel ?? (this.fsProvider.type === 'ssh' ? 'light' : 'deep');
|
||||
|
||||
if (!(await this.fsProvider.exists(projectPath))) {
|
||||
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
||||
|
|
@ -524,7 +517,7 @@ export class ProjectScanner {
|
|||
// This is slower but provides exact totalCount.
|
||||
let validSessionIds: Set<string> | null = null;
|
||||
let totalCount = 0;
|
||||
if (prefilterAll && shouldFilterNoise) {
|
||||
if (prefilterAll && shouldFilterNoise && metadataLevel === 'deep') {
|
||||
const contentResults = await Promise.allSettled(
|
||||
fileInfos.map(async (fileInfo) => ({
|
||||
sessionId: fileInfo.sessionId,
|
||||
|
|
@ -613,34 +606,16 @@ export class ProjectScanner {
|
|||
const toBuild = withContent.slice(0, needed);
|
||||
|
||||
const builtSessions = await Promise.all(
|
||||
toBuild.map(async ({ fileInfo }) => {
|
||||
try {
|
||||
return await this.buildSessionMetadata(
|
||||
projectId,
|
||||
fileInfo.sessionId,
|
||||
fileInfo.filePath,
|
||||
decodedPath,
|
||||
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
|
||||
);
|
||||
}
|
||||
})
|
||||
toBuild.map(({ fileInfo }) =>
|
||||
this.buildSessionForListing(
|
||||
metadataLevel,
|
||||
projectId,
|
||||
fileInfo.sessionId,
|
||||
fileInfo.filePath,
|
||||
decodedPath,
|
||||
fileInfo.mtimeMs
|
||||
)
|
||||
)
|
||||
);
|
||||
sessions.push(...builtSessions);
|
||||
|
||||
|
|
@ -701,8 +676,7 @@ export class ProjectScanner {
|
|||
projectPath: string,
|
||||
prefetchedMtimeMs?: number
|
||||
): Promise<Session> {
|
||||
const usePrefetchedTimes =
|
||||
this.fsProvider.type === 'ssh' && typeof prefetchedMtimeMs === 'number';
|
||||
const usePrefetchedTimes = typeof prefetchedMtimeMs === 'number';
|
||||
const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath);
|
||||
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ export class WorktreeGrouper {
|
|||
|
||||
// 2. Filter sessions for each project to only include non-noise sessions
|
||||
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(
|
||||
projects.map(async (project) => {
|
||||
const baseDir = extractBaseDir(project.id);
|
||||
|
|
|
|||
|
|
@ -284,4 +284,11 @@ export interface SessionsPaginationOptions {
|
|||
* @default true
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
if (limit) params.set('limit', String(limit));
|
||||
if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false');
|
||||
if (options?.prefilterAll === false) params.set('prefilterAll', 'false');
|
||||
if (options?.metadataLevel) params.set('metadataLevel', options.metadataLevel);
|
||||
const qs = params.toString();
|
||||
const encodedId = encodeURIComponent(projectId);
|
||||
const path = `/api/projects/${encodedId}/sessions-paginated`;
|
||||
|
|
|
|||
|
|
@ -95,9 +95,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
sessionsTotalCount: 0,
|
||||
});
|
||||
try {
|
||||
const { connectionMode } = get();
|
||||
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
||||
});
|
||||
set({
|
||||
sessions: result.sessions,
|
||||
|
|
@ -129,9 +131,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
|
||||
set({ sessionsLoadingMore: true });
|
||||
try {
|
||||
const { connectionMode } = get();
|
||||
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
||||
});
|
||||
set((prevState) => {
|
||||
// Deduplicate: pinned sessions fetched earlier may appear in paginated results
|
||||
|
|
@ -209,9 +213,11 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
projectRefreshGeneration.set(projectId, generation);
|
||||
|
||||
try {
|
||||
const { connectionMode } = get();
|
||||
const result = await api.getSessionsPaginated(projectId, null, 20, {
|
||||
includeTotalCount: false,
|
||||
prefilterAll: false,
|
||||
metadataLevel: connectionMode === 'ssh' ? 'light' : 'deep',
|
||||
});
|
||||
|
||||
// Drop stale responses from older in-flight refreshes
|
||||
|
|
|
|||
Loading…
Reference in a new issue