feat(sessions): add support for fetching sessions by IDs
- Implemented a new HTTP endpoint and IPC handler for retrieving session metadata based on an array of session IDs, allowing for the loading of pinned sessions that may not be included in paginated results. - Enhanced session management by validating session IDs and capping the number of IDs processed to improve performance and reliability. - Updated the renderer and store to handle the new functionality, ensuring that pinned sessions are preserved and loaded correctly. This commit enhances the application's session handling capabilities, providing users with better access to their pinned sessions.
This commit is contained in:
parent
e057f05ce4
commit
2193b2ed8a
12 changed files with 331 additions and 58 deletions
|
|
@ -82,6 +82,51 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 results = await Promise.all(
|
||||||
|
validIds.map((id) => services.projectScanner.getSession(validated.value!, id))
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
// Session detail
|
||||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||||
'/api/projects/:projectId/sessions/:sessionId',
|
'/api/projects/:projectId/sessions/:sessionId',
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function initializeSessionHandlers(contextRegistry: ServiceContextRegistr
|
||||||
export function registerSessionHandlers(ipcMain: IpcMain): void {
|
export function registerSessionHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle('get-sessions', handleGetSessions);
|
ipcMain.handle('get-sessions', handleGetSessions);
|
||||||
ipcMain.handle('get-sessions-paginated', handleGetSessionsPaginated);
|
ipcMain.handle('get-sessions-paginated', handleGetSessionsPaginated);
|
||||||
|
ipcMain.handle('get-sessions-by-ids', handleGetSessionsByIds);
|
||||||
ipcMain.handle('get-session-detail', handleGetSessionDetail);
|
ipcMain.handle('get-session-detail', handleGetSessionDetail);
|
||||||
ipcMain.handle('get-session-groups', handleGetSessionGroups);
|
ipcMain.handle('get-session-groups', handleGetSessionGroups);
|
||||||
ipcMain.handle('get-session-metrics', handleGetSessionMetrics);
|
ipcMain.handle('get-session-metrics', handleGetSessionMetrics);
|
||||||
|
|
@ -59,6 +60,7 @@ export function registerSessionHandlers(ipcMain: IpcMain): void {
|
||||||
export function removeSessionHandlers(ipcMain: IpcMain): void {
|
export function removeSessionHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('get-sessions');
|
ipcMain.removeHandler('get-sessions');
|
||||||
ipcMain.removeHandler('get-sessions-paginated');
|
ipcMain.removeHandler('get-sessions-paginated');
|
||||||
|
ipcMain.removeHandler('get-sessions-by-ids');
|
||||||
ipcMain.removeHandler('get-session-detail');
|
ipcMain.removeHandler('get-session-detail');
|
||||||
ipcMain.removeHandler('get-session-groups');
|
ipcMain.removeHandler('get-session-groups');
|
||||||
ipcMain.removeHandler('get-session-metrics');
|
ipcMain.removeHandler('get-session-metrics');
|
||||||
|
|
@ -130,6 +132,58 @@ async function handleGetSessionsPaginated(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for 'get-sessions-by-ids' IPC call.
|
||||||
|
* Fetches lightweight session metadata for specific session IDs.
|
||||||
|
* Used to load pinned sessions that may not be in the paginated list.
|
||||||
|
*/
|
||||||
|
async function handleGetSessionsByIds(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectId: string,
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<Session[]> {
|
||||||
|
try {
|
||||||
|
const validatedProject = validateProjectId(projectId);
|
||||||
|
if (!validatedProject.valid) {
|
||||||
|
logger.error(
|
||||||
|
`get-sessions-by-ids rejected: ${validatedProject.error ?? 'Invalid projectId'}`
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(sessionIds)) {
|
||||||
|
logger.error('get-sessions-by-ids rejected: sessionIds must be an array');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 50 IDs
|
||||||
|
const capped = sessionIds.slice(0, 50);
|
||||||
|
|
||||||
|
// Validate each session ID
|
||||||
|
const validIds: string[] = [];
|
||||||
|
for (const id of capped) {
|
||||||
|
const validated = validateSessionId(id);
|
||||||
|
if (validated.valid) {
|
||||||
|
validIds.push(validated.value!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectScanner } = registry.getActive();
|
||||||
|
const results = await Promise.all(
|
||||||
|
validIds.map((id) => projectScanner.getSession(validatedProject.value!, id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter((s): s is Session => s !== null);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in get-sessions-by-ids for project ${projectId}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for 'get-session-detail' IPC call.
|
* Handler for 'get-session-detail' IPC call.
|
||||||
* Gets full session detail including parsed chunks and subagents.
|
* Gets full session detail including parsed chunks and subagents.
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@ export class ProjectPathResolver {
|
||||||
? opts.sessionPaths
|
? opts.sessionPaths
|
||||||
: await this.listSessionPaths(projectId);
|
: await this.listSessionPaths(projectId);
|
||||||
|
|
||||||
for (const sessionPath of sessionPaths) {
|
// In SSH mode, avoid scanning every remote session file just to resolve display path.
|
||||||
|
// One successful cwd extraction is sufficient.
|
||||||
|
const maxPathsToInspect = this.fsProvider.type === 'ssh' ? 1 : sessionPaths.length;
|
||||||
|
for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) {
|
||||||
try {
|
try {
|
||||||
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
||||||
if (cwd && path.isAbsolute(cwd)) {
|
if (cwd && path.isAbsolute(cwd)) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
type SearchSessionsResult,
|
type SearchSessionsResult,
|
||||||
type Session,
|
type Session,
|
||||||
type SessionCursor,
|
type SessionCursor,
|
||||||
|
type SessionMetadataLevel,
|
||||||
type SessionsPaginationOptions,
|
type SessionsPaginationOptions,
|
||||||
} from '@main/types';
|
} from '@main/types';
|
||||||
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
|
import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl';
|
||||||
|
|
@ -44,7 +45,7 @@ import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvid
|
||||||
import { SessionContentFilter } from './SessionContentFilter';
|
import { SessionContentFilter } from './SessionContentFilter';
|
||||||
import { subprojectRegistry } from './SubprojectRegistry';
|
import { subprojectRegistry } from './SubprojectRegistry';
|
||||||
|
|
||||||
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
|
import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider';
|
||||||
|
|
||||||
const logger = createLogger('Discovery:ProjectScanner');
|
const logger = createLogger('Discovery:ProjectScanner');
|
||||||
import { ProjectPathResolver } from './ProjectPathResolver';
|
import { ProjectPathResolver } from './ProjectPathResolver';
|
||||||
|
|
@ -97,6 +98,7 @@ export class ProjectScanner {
|
||||||
* @returns Promise resolving to projects sorted by most recent activity
|
* @returns Promise resolving to projects sorted by most recent activity
|
||||||
*/
|
*/
|
||||||
async scan(): Promise<Project[]> {
|
async scan(): Promise<Project[]> {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
if (!(await this.fsProvider.exists(this.projectsDir))) {
|
if (!(await this.fsProvider.exists(this.projectsDir))) {
|
||||||
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
|
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
|
||||||
|
|
@ -120,6 +122,12 @@ export class ProjectScanner {
|
||||||
const validProjects = projectArrays.flat();
|
const validProjects = projectArrays.flat();
|
||||||
validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
||||||
|
|
||||||
|
if (this.fsProvider.type === 'ssh') {
|
||||||
|
logger.debug(
|
||||||
|
`SSH scan completed: ${validProjects.length} projects in ${Date.now() - startedAt}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return validProjects;
|
return validProjects;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error scanning projects directory:', error);
|
logger.error('Error scanning projects directory:', error);
|
||||||
|
|
@ -207,7 +215,7 @@ export class ProjectScanner {
|
||||||
this.fsProvider.type === 'ssh' ? 32 : 128,
|
this.fsProvider.type === 'ssh' ? 32 : 128,
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const { mtimeMs, birthtimeMs } = await this.resolveFileTimes(file, filePath);
|
||||||
let cwd: string | null = null;
|
let cwd: string | null = null;
|
||||||
|
|
||||||
// Over SSH, avoid reading every file body during project discovery.
|
// Over SSH, avoid reading every file body during project discovery.
|
||||||
|
|
@ -222,8 +230,8 @@ export class ProjectScanner {
|
||||||
return {
|
return {
|
||||||
sessionId: extractSessionId(file.name),
|
sessionId: extractSessionId(file.name),
|
||||||
filePath,
|
filePath,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs,
|
||||||
birthtimeMs: stats.birthtimeMs,
|
birthtimeMs,
|
||||||
cwd,
|
cwd,
|
||||||
} satisfies SessionInfo;
|
} satisfies SessionInfo;
|
||||||
}
|
}
|
||||||
|
|
@ -393,16 +401,38 @@ export class ProjectScanner {
|
||||||
sessionFiles.map(async (file) => {
|
sessionFiles.map(async (file) => {
|
||||||
const sessionId = extractSessionId(file.name);
|
const sessionId = extractSessionId(file.name);
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
|
const prefetchedMtimeMs = file.mtimeMs;
|
||||||
|
|
||||||
if (shouldFilterNoise) {
|
if (shouldFilterNoise) {
|
||||||
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
||||||
const hasContent = await this.hasDisplayableContent(filePath);
|
const hasContent = await this.hasDisplayableContent(filePath, prefetchedMtimeMs);
|
||||||
if (!hasContent) {
|
if (!hasContent) {
|
||||||
return null; // Filter out noise-only sessions
|
return null; // Filter out noise-only sessions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -434,6 +464,7 @@ export class ProjectScanner {
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
options?: SessionsPaginationOptions
|
options?: SessionsPaginationOptions
|
||||||
): Promise<PaginatedSessionsResult> {
|
): Promise<PaginatedSessionsResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const includeTotalCount = options?.includeTotalCount ?? false;
|
const includeTotalCount = options?.includeTotalCount ?? false;
|
||||||
const prefilterAll = options?.prefilterAll ?? false;
|
const prefilterAll = options?.prefilterAll ?? false;
|
||||||
|
|
@ -469,13 +500,13 @@ export class ProjectScanner {
|
||||||
this.fsProvider.type === 'ssh' ? 48 : 200,
|
this.fsProvider.type === 'ssh' ? 48 : 200,
|
||||||
async (file) => {
|
async (file) => {
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const { mtimeMs } = await this.resolveFileTimes(file, filePath);
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
sessionId: extractSessionId(file.name),
|
sessionId: extractSessionId(file.name),
|
||||||
timestamp: stats.mtimeMs,
|
timestamp: mtimeMs,
|
||||||
filePath,
|
filePath,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs,
|
||||||
} satisfies SessionFileInfo;
|
} satisfies SessionFileInfo;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -581,23 +612,37 @@ export class ProjectScanner {
|
||||||
const needed = limit + 1 - sessions.length;
|
const needed = limit + 1 - sessions.length;
|
||||||
const toBuild = withContent.slice(0, needed);
|
const toBuild = withContent.slice(0, needed);
|
||||||
|
|
||||||
const metadataResults = await Promise.allSettled(
|
const builtSessions = await Promise.all(
|
||||||
toBuild.map(({ fileInfo }) =>
|
toBuild.map(async ({ fileInfo }) => {
|
||||||
this.buildSessionMetadata(
|
try {
|
||||||
projectId,
|
return await this.buildSessionMetadata(
|
||||||
fileInfo.sessionId,
|
projectId,
|
||||||
fileInfo.filePath,
|
fileInfo.sessionId,
|
||||||
decodedPath,
|
fileInfo.filePath,
|
||||||
fileInfo.mtimeMs
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
for (const result of metadataResults) {
|
logger.debug(
|
||||||
if (result.status === 'fulfilled') {
|
`SSH page metadata parse failed for ${fileInfo.sessionId}, using light fallback`,
|
||||||
sessions.push(result.value);
|
error
|
||||||
}
|
);
|
||||||
}
|
return this.buildLightSessionMetadata(
|
||||||
|
projectId,
|
||||||
|
fileInfo.sessionId,
|
||||||
|
fileInfo.filePath,
|
||||||
|
decodedPath,
|
||||||
|
fileInfo.mtimeMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
sessions.push(...builtSessions);
|
||||||
|
|
||||||
batchStart = batchEnd;
|
batchStart = batchEnd;
|
||||||
}
|
}
|
||||||
|
|
@ -626,12 +671,20 @@ export class ProjectScanner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: PaginatedSessionsResult = {
|
||||||
sessions: pageSessions,
|
sessions: pageSessions,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
hasMore: nextCursor !== null,
|
hasMore: nextCursor !== null,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this.fsProvider.type === 'ssh') {
|
||||||
|
logger.debug(
|
||||||
|
`SSH listSessionsPaginated(${projectId}) returned ${result.sessions.length} sessions in ${Date.now() - startedAt}ms (hasMore=${result.hasMore})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error listing paginated sessions for project ${projectId}:`, error);
|
logger.error(`Error listing paginated sessions for project ${projectId}:`, error);
|
||||||
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
|
||||||
|
|
@ -648,8 +701,11 @@ export class ProjectScanner {
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
prefetchedMtimeMs?: number
|
prefetchedMtimeMs?: number
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const usePrefetchedTimes =
|
||||||
const effectiveMtime = prefetchedMtimeMs ?? stats.mtimeMs;
|
this.fsProvider.type === 'ssh' && typeof prefetchedMtimeMs === 'number';
|
||||||
|
const stats = usePrefetchedTimes ? null : await this.fsProvider.stat(filePath);
|
||||||
|
const effectiveMtime = prefetchedMtimeMs ?? stats?.mtimeMs ?? Date.now();
|
||||||
|
const birthtimeMs = stats?.birthtimeMs ?? effectiveMtime;
|
||||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||||
const metadata =
|
const metadata =
|
||||||
cachedMetadata?.mtimeMs === effectiveMtime
|
cachedMetadata?.mtimeMs === effectiveMtime
|
||||||
|
|
@ -664,19 +720,49 @@ export class ProjectScanner {
|
||||||
this.subagentLocator.hasSubagents(projectId, sessionId),
|
this.subagentLocator.hasSubagents(projectId, sessionId),
|
||||||
this.loadTodoData(sessionId),
|
this.loadTodoData(sessionId),
|
||||||
]);
|
]);
|
||||||
|
const metadataLevel: SessionMetadataLevel = 'deep';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
projectPath,
|
projectPath,
|
||||||
todoData,
|
todoData,
|
||||||
createdAt: Math.floor(stats.birthtimeMs),
|
createdAt: Math.floor(birthtimeMs),
|
||||||
firstMessage: metadata.firstUserMessage?.text,
|
firstMessage: metadata.firstUserMessage?.text,
|
||||||
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
messageTimestamp: metadata.firstUserMessage?.timestamp,
|
||||||
hasSubagents,
|
hasSubagents,
|
||||||
messageCount: metadata.messageCount,
|
messageCount: metadata.messageCount,
|
||||||
isOngoing: metadata.isOngoing,
|
isOngoing: metadata.isOngoing,
|
||||||
gitBranch: metadata.gitBranch ?? undefined,
|
gitBranch: metadata.gitBranch ?? undefined,
|
||||||
|
metadataLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lightweight session record using filesystem metadata only.
|
||||||
|
* Used as SSH fallback when deep parsing fails transiently.
|
||||||
|
*/
|
||||||
|
private async buildLightSessionMetadata(
|
||||||
|
projectId: string,
|
||||||
|
sessionId: string,
|
||||||
|
filePath: string,
|
||||||
|
projectPath: string,
|
||||||
|
prefetchedMtimeMs?: number
|
||||||
|
): Promise<Session> {
|
||||||
|
const times =
|
||||||
|
typeof prefetchedMtimeMs === 'number'
|
||||||
|
? { mtimeMs: prefetchedMtimeMs, birthtimeMs: prefetchedMtimeMs }
|
||||||
|
: await this.resolveFileTimes(undefined, filePath);
|
||||||
|
const metadataLevel: SessionMetadataLevel = 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sessionId,
|
||||||
|
projectId,
|
||||||
|
projectPath,
|
||||||
|
createdAt: Math.floor(times.birthtimeMs),
|
||||||
|
hasSubagents: false,
|
||||||
|
messageCount: 0,
|
||||||
|
metadataLevel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -844,6 +930,27 @@ export class ProjectScanner {
|
||||||
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
|
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve best-available file timestamps from directory entry metadata or stat fallback.
|
||||||
|
*/
|
||||||
|
private async resolveFileTimes(
|
||||||
|
entry: FsDirent | undefined,
|
||||||
|
filePath: string
|
||||||
|
): Promise<{ mtimeMs: number; birthtimeMs: number }> {
|
||||||
|
if (entry && typeof entry.mtimeMs === 'number') {
|
||||||
|
return {
|
||||||
|
mtimeMs: entry.mtimeMs,
|
||||||
|
birthtimeMs: entry.birthtimeMs ?? entry.mtimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
|
return {
|
||||||
|
mtimeMs: stats.mtimeMs,
|
||||||
|
birthtimeMs: stats.birthtimeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs async mapping in bounded batches and returns only fulfilled results.
|
* Runs async mapping in bounded batches and returns only fulfilled results.
|
||||||
* This prevents overwhelming SFTP servers with unbounded parallel requests.
|
* This prevents overwhelming SFTP servers with unbounded parallel requests.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ export interface FsDirent {
|
||||||
name: string;
|
name: string;
|
||||||
isFile(): boolean;
|
isFile(): boolean;
|
||||||
isDirectory(): boolean;
|
isDirectory(): boolean;
|
||||||
|
/** Optional metadata provided by some providers (e.g., SFTP readdir attrs) */
|
||||||
|
size?: number;
|
||||||
|
/** Optional mtime in milliseconds since epoch */
|
||||||
|
mtimeMs?: number;
|
||||||
|
/** Optional birthtime/ctime fallback in milliseconds since epoch */
|
||||||
|
birthtimeMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -412,15 +412,16 @@ export class FileWatcher extends EventEmitter {
|
||||||
|
|
||||||
const fullPath = path.join(projectPath, entry.name);
|
const fullPath = path.join(projectPath, entry.name);
|
||||||
try {
|
try {
|
||||||
const stats = await this.fsProvider.stat(fullPath);
|
const observedSize =
|
||||||
|
typeof entry.size === 'number' ? entry.size : (await this.fsProvider.stat(fullPath)).size;
|
||||||
const lastSize = this.polledFileSizes.get(fullPath);
|
const lastSize = this.polledFileSizes.get(fullPath);
|
||||||
|
|
||||||
if (lastSize === undefined) {
|
if (lastSize === undefined) {
|
||||||
// First time seeing this file
|
// First time seeing this file
|
||||||
this.polledFileSizes.set(fullPath, stats.size);
|
this.polledFileSizes.set(fullPath, observedSize);
|
||||||
} else if (stats.size !== lastSize) {
|
} else if (observedSize !== lastSize) {
|
||||||
// File changed
|
// File changed
|
||||||
this.polledFileSizes.set(fullPath, stats.size);
|
this.polledFileSizes.set(fullPath, observedSize);
|
||||||
this.handleProjectsChange('change', path.join(dir.name, entry.name));
|
this.handleProjectsChange('change', path.join(dir.name, entry.name));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import type { SFTPWrapper } from 'ssh2';
|
||||||
|
|
||||||
const logger = createLogger('Infrastructure:SshFileSystemProvider');
|
const logger = createLogger('Infrastructure:SshFileSystemProvider');
|
||||||
|
|
||||||
|
export type SftpErrorKind = 'not_found' | 'transient' | 'permanent';
|
||||||
|
|
||||||
export class SshFileSystemProvider implements FileSystemProvider {
|
export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
private static readonly MAX_RETRIES = 3;
|
private static readonly MAX_RETRIES = 3;
|
||||||
private static readonly RETRY_BASE_DELAY_MS = 75;
|
private static readonly RETRY_BASE_DELAY_MS = 75;
|
||||||
|
|
@ -34,12 +36,13 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
await this.stat(filePath);
|
await this.stat(filePath);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isNotFoundError(error)) {
|
const errorKind = this.classifySftpError(error);
|
||||||
|
if (errorKind === 'not_found') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For transient SFTP failures (e.g. code=4), avoid false negatives.
|
// For transient SFTP failures (e.g. code=4), avoid false negatives.
|
||||||
if (this.isRetryableError(error)) {
|
if (errorKind === 'transient') {
|
||||||
const code = this.getErrorCode(error);
|
const code = this.getErrorCode(error);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`exists(${filePath}) got retryable SFTP error (${String(code)}); treating path as potentially present`
|
`exists(${filePath}) got retryable SFTP error (${String(code)}); treating path as potentially present`
|
||||||
|
|
@ -66,10 +69,7 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (
|
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
|
||||||
this.isRetryableError(error) &&
|
|
||||||
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
||||||
) {
|
|
||||||
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -108,10 +108,7 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (
|
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
|
||||||
this.isRetryableError(error) &&
|
|
||||||
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
||||||
) {
|
|
||||||
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -139,17 +136,16 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
const entries: FsDirent[] = [];
|
const entries: FsDirent[] = [];
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
const mode = item.attrs.mode;
|
const mode = item.attrs.mode;
|
||||||
entries.push(this.buildDirent(item.filename, mode, S_IFMT, S_IFREG, S_IFDIR));
|
entries.push(
|
||||||
|
this.buildDirent(item.filename, mode, S_IFMT, S_IFREG, S_IFDIR, item.attrs.size, item.attrs.mtime)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
resolve(entries);
|
resolve(entries);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (
|
if (this.classifySftpError(error) === 'transient' && attempt < SshFileSystemProvider.MAX_RETRIES) {
|
||||||
this.isRetryableError(error) &&
|
|
||||||
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
||||||
) {
|
|
||||||
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -227,17 +223,33 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private classifySftpError(error: unknown): SftpErrorKind {
|
||||||
|
if (this.isNotFoundError(error)) {
|
||||||
|
return 'not_found';
|
||||||
|
}
|
||||||
|
if (this.isRetryableError(error)) {
|
||||||
|
return 'transient';
|
||||||
|
}
|
||||||
|
return 'permanent';
|
||||||
|
}
|
||||||
|
|
||||||
private buildDirent(
|
private buildDirent(
|
||||||
filename: string,
|
filename: string,
|
||||||
mode: number,
|
mode: number,
|
||||||
sifmt: number,
|
sifmt: number,
|
||||||
sifreg: number,
|
sifreg: number,
|
||||||
sifdir: number
|
sifdir: number,
|
||||||
|
size?: number,
|
||||||
|
mtimeSeconds?: number
|
||||||
): FsDirent {
|
): FsDirent {
|
||||||
|
const mtimeMs = typeof mtimeSeconds === 'number' ? mtimeSeconds * 1000 : undefined;
|
||||||
return {
|
return {
|
||||||
name: filename,
|
name: filename,
|
||||||
isFile: () => (mode & sifmt) === sifreg,
|
isFile: () => (mode & sifmt) === sifreg,
|
||||||
isDirectory: () => (mode & sifmt) === sifdir,
|
isDirectory: () => (mode & sifmt) === sifdir,
|
||||||
|
size,
|
||||||
|
mtimeMs,
|
||||||
|
birthtimeMs: mtimeMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ export interface Project {
|
||||||
/**
|
/**
|
||||||
* Session metadata and summary.
|
* Session metadata and summary.
|
||||||
*/
|
*/
|
||||||
|
export type SessionMetadataLevel = 'light' | 'deep';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
/** Session UUID (JSONL filename without extension) */
|
/** Session UUID (JSONL filename without extension) */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -85,6 +87,8 @@ export interface Session {
|
||||||
isOngoing?: boolean;
|
isOngoing?: boolean;
|
||||||
/** Git branch name if available */
|
/** Git branch name if available */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
|
/** Metadata completeness level */
|
||||||
|
metadataLevel?: SessionMetadataLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ const electronAPI: ElectronAPI = {
|
||||||
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
|
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
|
||||||
getSessionGroups: (projectId: string, sessionId: string) =>
|
getSessionGroups: (projectId: string, sessionId: string) =>
|
||||||
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
|
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
|
||||||
|
getSessionsByIds: (projectId: string, sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds),
|
||||||
|
|
||||||
// Repository grouping (worktree support)
|
// Repository grouping (worktree support)
|
||||||
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),
|
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,11 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
|
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getSessionsByIds = (projectId: string, sessionIds: string[]): Promise<Session[]> =>
|
||||||
|
this.post<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions-by-ids`, {
|
||||||
|
sessionIds,
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Repository grouping
|
// Repository grouping
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -133,12 +133,17 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
includeTotalCount: false,
|
includeTotalCount: false,
|
||||||
prefilterAll: false,
|
prefilterAll: false,
|
||||||
});
|
});
|
||||||
set((prevState) => ({
|
set((prevState) => {
|
||||||
sessions: [...prevState.sessions, ...result.sessions],
|
// Deduplicate: pinned sessions fetched earlier may appear in paginated results
|
||||||
sessionsCursor: result.nextCursor,
|
const existingIds = new Set(prevState.sessions.map((s) => s.id));
|
||||||
sessionsHasMore: result.hasMore,
|
const newSessions = result.sessions.filter((s) => !existingIds.has(s.id));
|
||||||
sessionsLoadingMore: false,
|
return {
|
||||||
}));
|
sessions: [...prevState.sessions, ...newSessions],
|
||||||
|
sessionsCursor: result.nextCursor,
|
||||||
|
sessionsHasMore: result.hasMore,
|
||||||
|
sessionsLoadingMore: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
sessionsError: error instanceof Error ? error.message : 'Failed to fetch more sessions',
|
sessionsError: error instanceof Error ? error.message : 'Failed to fetch more sessions',
|
||||||
|
|
@ -214,9 +219,17 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve pinned sessions that are beyond page 1
|
||||||
|
const { pinnedSessionIds, sessions: prevSessions } = get();
|
||||||
|
const newPageIds = new Set(result.sessions.map((s) => s.id));
|
||||||
|
const pinnedSet = new Set(pinnedSessionIds);
|
||||||
|
const pinnedToRetain = prevSessions.filter(
|
||||||
|
(s) => pinnedSet.has(s.id) && !newPageIds.has(s.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Update sessions without loading state
|
// Update sessions without loading state
|
||||||
set({
|
set({
|
||||||
sessions: result.sessions,
|
sessions: [...result.sessions, ...pinnedToRetain],
|
||||||
sessionsCursor: result.nextCursor,
|
sessionsCursor: result.nextCursor,
|
||||||
sessionsHasMore: result.hasMore,
|
sessionsHasMore: result.hasMore,
|
||||||
sessionsTotalCount: result.totalCount,
|
sessionsTotalCount: result.totalCount,
|
||||||
|
|
@ -258,6 +271,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
},
|
},
|
||||||
|
|
||||||
// Load pinned sessions from config for current project
|
// Load pinned sessions from config for current project
|
||||||
|
// Fetches missing pinned session data that may be beyond the paginated page
|
||||||
loadPinnedSessions: async () => {
|
loadPinnedSessions: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const projectId = state.selectedProjectId;
|
const projectId = state.selectedProjectId;
|
||||||
|
|
@ -269,7 +283,26 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
try {
|
try {
|
||||||
const config = await api.config.get();
|
const config = await api.config.get();
|
||||||
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
|
||||||
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
|
const pinnedIds = pins.map((p) => p.sessionId);
|
||||||
|
set({ pinnedSessionIds: pinnedIds });
|
||||||
|
|
||||||
|
// Determine which pinned sessions are missing from the loaded sessions array
|
||||||
|
const currentSessions = get().sessions;
|
||||||
|
const loadedIds = new Set(currentSessions.map((s) => s.id));
|
||||||
|
const missingIds = pinnedIds.filter((id) => !loadedIds.has(id));
|
||||||
|
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
const missingSessions = await api.getSessionsByIds(projectId, missingIds);
|
||||||
|
if (missingSessions.length > 0) {
|
||||||
|
// Re-read sessions in case they changed during the async call
|
||||||
|
const latestSessions = get().sessions;
|
||||||
|
const latestIds = new Set(latestSessions.map((s) => s.id));
|
||||||
|
const toAppend = missingSessions.filter((s) => !latestIds.has(s.id));
|
||||||
|
if (toAppend.length > 0) {
|
||||||
|
set({ sessions: [...latestSessions, ...toAppend] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('loadPinnedSessions error:', error);
|
logger.error('loadPinnedSessions error:', error);
|
||||||
set({ pinnedSessionIds: [] });
|
set({ pinnedSessionIds: [] });
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@ export interface ElectronAPI {
|
||||||
subagentId: string
|
subagentId: string
|
||||||
) => Promise<SubagentDetail | null>;
|
) => Promise<SubagentDetail | null>;
|
||||||
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
|
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
|
||||||
|
getSessionsByIds: (projectId: string, sessionIds: string[]) => Promise<Session[]>;
|
||||||
|
|
||||||
// Repository grouping (worktree support)
|
// Repository grouping (worktree support)
|
||||||
getRepositoryGroups: () => Promise<RepositoryGroup[]>;
|
getRepositoryGroups: () => Promise<RepositoryGroup[]>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue