feat(ssh): enhance SSH project handling and performance
- Added support for remote todos path in SSH handlers, improving project organization. - Updated ProjectPathResolver to utilize the filesystem provider for extracting current working directory. - Optimized ProjectScanner to handle session file processing in batches, enhancing performance over SSH. - Implemented noise filtering logic in WorktreeGrouper and FileWatcher to improve session management. - Enhanced SshFileSystemProvider with retry logic for file operations, increasing reliability during transient errors. This commit significantly improves the handling of SSH projects and enhances overall performance and reliability in file operations.
This commit is contained in:
parent
971fc142b2
commit
e057f05ce4
7 changed files with 344 additions and 122 deletions
|
|
@ -19,6 +19,7 @@ import {
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import { configManager, ServiceContext } from '../services';
|
import { configManager, ServiceContext } from '../services';
|
||||||
|
|
||||||
|
|
@ -74,6 +75,9 @@ export function registerSshHandlers(ipcMain: IpcMain): void {
|
||||||
// Get provider and remote path
|
// Get provider and remote path
|
||||||
const provider = connectionManager.getProvider();
|
const provider = connectionManager.getProvider();
|
||||||
const remoteProjectsPath = connectionManager.getRemoteProjectsPath() ?? undefined;
|
const remoteProjectsPath = connectionManager.getRemoteProjectsPath() ?? undefined;
|
||||||
|
const remoteTodosPath = remoteProjectsPath
|
||||||
|
? path.join(path.dirname(remoteProjectsPath), 'todos')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Generate context ID
|
// Generate context ID
|
||||||
const contextId = `ssh-${config.host}`;
|
const contextId = `ssh-${config.host}`;
|
||||||
|
|
@ -90,6 +94,7 @@ export function registerSshHandlers(ipcMain: IpcMain): void {
|
||||||
type: 'ssh',
|
type: 'ssh',
|
||||||
fsProvider: provider,
|
fsProvider: provider,
|
||||||
projectsDir: remoteProjectsPath,
|
projectsDir: remoteProjectsPath,
|
||||||
|
todosDir: remoteTodosPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register and start SSH context
|
// Register and start SSH context
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ export class ProjectPathResolver {
|
||||||
|
|
||||||
for (const sessionPath of sessionPaths) {
|
for (const sessionPath of sessionPaths) {
|
||||||
try {
|
try {
|
||||||
const cwd = await extractCwd(sessionPath);
|
const cwd = await extractCwd(sessionPath, this.fsProvider);
|
||||||
if (cwd && path.isAbsolute(cwd)) {
|
if (cwd && path.isAbsolute(cwd)) {
|
||||||
this.projectPathCache.set(projectId, cwd);
|
this.projectPathCache.set(projectId, cwd);
|
||||||
return cwd;
|
return cwd;
|
||||||
|
|
|
||||||
|
|
@ -201,33 +201,45 @@ export class ProjectScanner {
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionInfos: SessionInfo[] = await Promise.all(
|
const shouldSplitByCwd = this.fsProvider.type !== 'ssh';
|
||||||
sessionFiles.map(async (file) => {
|
const sessionInfos = await this.collectFulfilledInBatches(
|
||||||
|
sessionFiles,
|
||||||
|
this.fsProvider.type === 'ssh' ? 32 : 128,
|
||||||
|
async (file) => {
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
let cwd: string | null = null;
|
let cwd: string | null = null;
|
||||||
try {
|
|
||||||
cwd = await extractCwd(filePath, this.fsProvider);
|
// Over SSH, avoid reading every file body during project discovery.
|
||||||
} catch {
|
if (shouldSplitByCwd) {
|
||||||
// Ignore unreadable files
|
try {
|
||||||
|
cwd = await extractCwd(filePath, this.fsProvider);
|
||||||
|
} catch {
|
||||||
|
// Ignore unreadable files
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId: extractSessionId(file.name),
|
sessionId: extractSessionId(file.name),
|
||||||
filePath,
|
filePath,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
birthtimeMs: stats.birthtimeMs,
|
birthtimeMs: stats.birthtimeMs,
|
||||||
cwd,
|
cwd,
|
||||||
};
|
} satisfies SessionInfo;
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (sessionInfos.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Group sessions by cwd
|
// Group sessions by cwd
|
||||||
const cwdGroups = new Map<string, SessionInfo[]>();
|
const cwdGroups = new Map<string, SessionInfo[]>();
|
||||||
const baseName = extractProjectName(encodedName);
|
const baseName = extractProjectName(encodedName);
|
||||||
const decodedFallback = baseName; // Used when cwd is null
|
const decodedFallback = baseName; // Used when cwd is null
|
||||||
|
|
||||||
for (const info of sessionInfos) {
|
for (const info of sessionInfos) {
|
||||||
const key = info.cwd ?? `__decoded__${decodedFallback}`;
|
const key = shouldSplitByCwd ? (info.cwd ?? `__decoded__${decodedFallback}`) : encodedName;
|
||||||
const group = cwdGroups.get(key) ?? [];
|
const group = cwdGroups.get(key) ?? [];
|
||||||
group.push(info);
|
group.push(info);
|
||||||
cwdGroups.set(key, group);
|
cwdGroups.set(key, group);
|
||||||
|
|
@ -360,6 +372,7 @@ export class ProjectScanner {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const baseDir = extractBaseDir(projectId);
|
||||||
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';
|
||||||
|
|
||||||
if (!(await this.fsProvider.exists(projectPath))) {
|
if (!(await this.fsProvider.exists(projectPath))) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -381,10 +394,12 @@ export class ProjectScanner {
|
||||||
const sessionId = extractSessionId(file.name);
|
const sessionId = extractSessionId(file.name);
|
||||||
const filePath = path.join(projectPath, file.name);
|
const filePath = path.join(projectPath, file.name);
|
||||||
|
|
||||||
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
if (shouldFilterNoise) {
|
||||||
const hasContent = await this.hasDisplayableContent(filePath);
|
// Check if session has non-noise messages (delegated to SessionContentFilter)
|
||||||
if (!hasContent) {
|
const hasContent = await this.hasDisplayableContent(filePath);
|
||||||
return null; // Filter out noise-only sessions
|
if (!hasContent) {
|
||||||
|
return null; // Filter out noise-only sessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
|
return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath);
|
||||||
|
|
@ -425,6 +440,7 @@ export class ProjectScanner {
|
||||||
const baseDir = extractBaseDir(projectId);
|
const baseDir = extractBaseDir(projectId);
|
||||||
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';
|
||||||
|
|
||||||
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 };
|
||||||
|
|
@ -439,7 +455,7 @@ export class ProjectScanner {
|
||||||
sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));
|
sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get stats for all session files
|
// Get stats for all session files (parallel for SSH performance)
|
||||||
interface SessionFileInfo {
|
interface SessionFileInfo {
|
||||||
name: string;
|
name: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|
@ -447,24 +463,22 @@ export class ProjectScanner {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
}
|
}
|
||||||
const fileInfos: SessionFileInfo[] = [];
|
|
||||||
|
|
||||||
for (const file of sessionFiles) {
|
const fileInfos = await this.collectFulfilledInBatches(
|
||||||
const filePath = path.join(projectPath, file.name);
|
sessionFiles,
|
||||||
try {
|
this.fsProvider.type === 'ssh' ? 48 : 200,
|
||||||
|
async (file) => {
|
||||||
|
const filePath = path.join(projectPath, file.name);
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
fileInfos.push({
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
sessionId: extractSessionId(file.name),
|
sessionId: extractSessionId(file.name),
|
||||||
timestamp: stats.mtimeMs,
|
timestamp: stats.mtimeMs,
|
||||||
filePath,
|
filePath,
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
});
|
} satisfies SessionFileInfo;
|
||||||
} catch {
|
|
||||||
// Skip files we can't stat
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
// Step 2: Sort by timestamp descending (most recent first)
|
// Step 2: Sort by timestamp descending (most recent first)
|
||||||
fileInfos.sort((a, b) => {
|
fileInfos.sort((a, b) => {
|
||||||
|
|
@ -479,11 +493,17 @@ 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) {
|
if (prefilterAll && shouldFilterNoise) {
|
||||||
|
const contentResults = await Promise.allSettled(
|
||||||
|
fileInfos.map(async (fileInfo) => ({
|
||||||
|
sessionId: fileInfo.sessionId,
|
||||||
|
hasContent: await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs),
|
||||||
|
}))
|
||||||
|
);
|
||||||
validSessionIds = new Set<string>();
|
validSessionIds = new Set<string>();
|
||||||
for (const fileInfo of fileInfos) {
|
for (const result of contentResults) {
|
||||||
if (await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs)) {
|
if (result.status === 'fulfilled' && result.value.hasContent) {
|
||||||
validSessionIds.add(fileInfo.sessionId);
|
validSessionIds.add(result.value.sessionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totalCount = validSessionIds.size;
|
totalCount = validSessionIds.size;
|
||||||
|
|
@ -519,35 +539,67 @@ export class ProjectScanner {
|
||||||
const sessions: Session[] = [];
|
const sessions: Session[] = [];
|
||||||
let scannedCandidates = 0;
|
let scannedCandidates = 0;
|
||||||
|
|
||||||
// Fast path: avoid pre-filtering everything. Scan until we have enough page items.
|
// Fetch page items in parallel batches for SSH performance.
|
||||||
for (let i = startIndex; i < fileInfos.length; i++) {
|
// Process candidates in chunks, checking content + building metadata concurrently.
|
||||||
const fileInfo = fileInfos[i];
|
const BATCH_SIZE = limit + 1; // One extra to detect hasMore
|
||||||
if (!fileInfo) {
|
let batchStart = startIndex;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
scannedCandidates++;
|
|
||||||
|
|
||||||
let hasContent: boolean;
|
while (sessions.length < limit + 1 && batchStart < fileInfos.length) {
|
||||||
|
// Take a batch of candidates (overshoot to account for filtered-out items)
|
||||||
|
const batchEnd = Math.min(batchStart + BATCH_SIZE * 2, fileInfos.length);
|
||||||
|
const batch = fileInfos.slice(batchStart, batchEnd);
|
||||||
|
scannedCandidates += batch.length;
|
||||||
|
|
||||||
|
// Step 5a: Check content in parallel
|
||||||
|
let contentBatch: { fileInfo: SessionFileInfo; hasContent: boolean }[];
|
||||||
if (validSessionIds) {
|
if (validSessionIds) {
|
||||||
hasContent = validSessionIds.has(fileInfo.sessionId);
|
contentBatch = batch.map((fileInfo) => ({
|
||||||
|
fileInfo,
|
||||||
|
hasContent: validSessionIds.has(fileInfo.sessionId),
|
||||||
|
}));
|
||||||
|
} else if (!shouldFilterNoise) {
|
||||||
|
contentBatch = batch.map((fileInfo) => ({ fileInfo, hasContent: true }));
|
||||||
} else {
|
} else {
|
||||||
hasContent = await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs);
|
const contentResults = await Promise.allSettled(
|
||||||
}
|
batch.map(async (fileInfo) => ({
|
||||||
if (!hasContent) {
|
fileInfo,
|
||||||
continue;
|
hasContent: await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
contentBatch = contentResults
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
r
|
||||||
|
): r is PromiseFulfilledResult<{ fileInfo: SessionFileInfo; hasContent: boolean }> =>
|
||||||
|
r.status === 'fulfilled'
|
||||||
|
)
|
||||||
|
.map((r) => r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.buildSessionMetadata(
|
// Step 5b: Build metadata in parallel for items with content
|
||||||
projectId,
|
const withContent = contentBatch.filter((c) => c.hasContent);
|
||||||
fileInfo.sessionId,
|
const needed = limit + 1 - sessions.length;
|
||||||
fileInfo.filePath,
|
const toBuild = withContent.slice(0, needed);
|
||||||
decodedPath
|
|
||||||
|
const metadataResults = await Promise.allSettled(
|
||||||
|
toBuild.map(({ fileInfo }) =>
|
||||||
|
this.buildSessionMetadata(
|
||||||
|
projectId,
|
||||||
|
fileInfo.sessionId,
|
||||||
|
fileInfo.filePath,
|
||||||
|
decodedPath,
|
||||||
|
fileInfo.mtimeMs
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
sessions.push(session);
|
|
||||||
|
|
||||||
if (sessions.length >= limit + 1) {
|
for (const result of metadataResults) {
|
||||||
break;
|
if (result.status === 'fulfilled') {
|
||||||
|
sessions.push(result.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batchStart = batchEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Build next cursor
|
// Step 6: Build next cursor
|
||||||
|
|
@ -593,23 +645,25 @@ export class ProjectScanner {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectPath: string
|
projectPath: string,
|
||||||
|
prefetchedMtimeMs?: number
|
||||||
): Promise<Session> {
|
): Promise<Session> {
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
|
const effectiveMtime = prefetchedMtimeMs ?? stats.mtimeMs;
|
||||||
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
const cachedMetadata = this.sessionMetadataCache.get(filePath);
|
||||||
const metadata =
|
const metadata =
|
||||||
cachedMetadata?.mtimeMs === stats.mtimeMs
|
cachedMetadata?.mtimeMs === effectiveMtime
|
||||||
? cachedMetadata.metadata
|
? cachedMetadata.metadata
|
||||||
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
|
||||||
if (cachedMetadata?.mtimeMs !== stats.mtimeMs) {
|
if (cachedMetadata?.mtimeMs !== effectiveMtime) {
|
||||||
this.sessionMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata });
|
this.sessionMetadataCache.set(filePath, { mtimeMs: effectiveMtime, metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for subagents (delegated to SubagentLocator)
|
// Check for subagents and load task list data in parallel
|
||||||
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
|
const [hasSubagents, todoData] = await Promise.all([
|
||||||
|
this.subagentLocator.hasSubagents(projectId, sessionId),
|
||||||
// Load task list data if exists
|
this.loadTodoData(sessionId),
|
||||||
const todoData = await this.loadTodoData(sessionId);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
|
@ -790,6 +844,31 @@ export class ProjectScanner {
|
||||||
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
|
return this.sessionSearcher.searchSessions(projectId, query, maxResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs async mapping in bounded batches and returns only fulfilled results.
|
||||||
|
* This prevents overwhelming SFTP servers with unbounded parallel requests.
|
||||||
|
*/
|
||||||
|
private async collectFulfilledInBatches<T, R>(
|
||||||
|
items: T[],
|
||||||
|
batchSize: number,
|
||||||
|
mapper: (item: T) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const safeBatchSize = Math.max(1, batchSize);
|
||||||
|
const results: R[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += safeBatchSize) {
|
||||||
|
const batch = items.slice(i, i + safeBatchSize);
|
||||||
|
const settled = await Promise.allSettled(batch.map((item) => mapper(item)));
|
||||||
|
for (const result of settled) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
results.push(result.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the project path for a given project ID.
|
* Resolves the project path for a given project ID.
|
||||||
* For composite IDs, uses the registry's cwd directly.
|
* For composite IDs, uses the registry's cwd directly.
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ 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';
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
projects.map(async (project) => {
|
projects.map(async (project) => {
|
||||||
const baseDir = extractBaseDir(project.id);
|
const baseDir = extractBaseDir(project.id);
|
||||||
|
|
@ -83,6 +84,11 @@ export class WorktreeGrouper {
|
||||||
if (sessionFilter && !sessionFilter.has(sessionId)) {
|
if (sessionFilter && !sessionFilter.has(sessionId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!shouldFilterNoise) {
|
||||||
|
filteredSessions.push(sessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
|
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
|
||||||
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath, this.fsProvider)) {
|
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath, this.fsProvider)) {
|
||||||
filteredSessions.push(sessionId);
|
filteredSessions.push(sessionId);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import { type DataCache } from './DataCache';
|
||||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||||
import { type NotificationManager } from './NotificationManager';
|
import { type NotificationManager } from './NotificationManager';
|
||||||
|
|
||||||
import type { FileSystemProvider } from './FileSystemProvider';
|
import type { FileSystemProvider, FsDirent } from './FileSystemProvider';
|
||||||
|
|
||||||
const logger = createLogger('Service:FileWatcher');
|
const logger = createLogger('Service:FileWatcher');
|
||||||
|
|
||||||
|
|
@ -73,7 +73,9 @@ export class FileWatcher extends EventEmitter {
|
||||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||||
private pollingTimer: NodeJS.Timeout | null = null;
|
private pollingTimer: NodeJS.Timeout | null = null;
|
||||||
/** Polling interval for SSH mode */
|
/** Polling interval for SSH mode */
|
||||||
private static readonly SSH_POLL_INTERVAL_MS = 5000;
|
private static readonly SSH_POLL_INTERVAL_MS = 10000;
|
||||||
|
/** Guard to prevent overlapping SSH polling runs */
|
||||||
|
private pollingInProgress = false;
|
||||||
/** Track file sizes for SSH polling change detection */
|
/** Track file sizes for SSH polling change detection */
|
||||||
private polledFileSizes = new Map<string, number>();
|
private polledFileSizes = new Map<string, number>();
|
||||||
/** Files currently being processed (concurrency guard) */
|
/** Files currently being processed (concurrency guard) */
|
||||||
|
|
@ -176,6 +178,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
clearInterval(this.pollingTimer);
|
clearInterval(this.pollingTimer);
|
||||||
this.pollingTimer = null;
|
this.pollingTimer = null;
|
||||||
}
|
}
|
||||||
|
this.pollingInProgress = false;
|
||||||
this.polledFileSizes.clear();
|
this.polledFileSizes.clear();
|
||||||
|
|
||||||
// Clear error detection tracking
|
// Clear error detection tracking
|
||||||
|
|
@ -372,9 +375,18 @@ export class FileWatcher extends EventEmitter {
|
||||||
|
|
||||||
logger.info('FileWatcher: Starting SSH polling mode');
|
logger.info('FileWatcher: Starting SSH polling mode');
|
||||||
this.pollingTimer = setInterval(() => {
|
this.pollingTimer = setInterval(() => {
|
||||||
this.pollForChanges().catch((err) => {
|
if (this.pollingInProgress) {
|
||||||
logger.error('Error during SSH polling:', err);
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
this.pollingInProgress = true;
|
||||||
|
this.pollForChanges()
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('Error during SSH polling:', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.pollingInProgress = false;
|
||||||
|
});
|
||||||
}, FileWatcher.SSH_POLL_INTERVAL_MS);
|
}, FileWatcher.SSH_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,14 +395,12 @@ export class FileWatcher extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
private async pollForChanges(): Promise<void> {
|
private async pollForChanges(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!(await this.fsProvider.exists(this.projectsPath))) return;
|
|
||||||
|
|
||||||
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
|
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
|
||||||
for (const dir of projectDirs) {
|
for (const dir of projectDirs) {
|
||||||
if (!dir.isDirectory()) continue;
|
if (!dir.isDirectory()) continue;
|
||||||
|
|
||||||
const projectPath = path.join(this.projectsPath, dir.name);
|
const projectPath = path.join(this.projectsPath, dir.name);
|
||||||
let entries: import('./FileSystemProvider').FsDirent[];
|
let entries: FsDirent[];
|
||||||
try {
|
try {
|
||||||
entries = await this.fsProvider.readdir(projectPath);
|
entries = await this.fsProvider.readdir(projectPath);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import type { SFTPWrapper } from 'ssh2';
|
||||||
const logger = createLogger('Infrastructure:SshFileSystemProvider');
|
const logger = createLogger('Infrastructure:SshFileSystemProvider');
|
||||||
|
|
||||||
export class SshFileSystemProvider implements FileSystemProvider {
|
export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
|
private static readonly MAX_RETRIES = 3;
|
||||||
|
private static readonly RETRY_BASE_DELAY_MS = 75;
|
||||||
|
|
||||||
readonly type = 'ssh' as const;
|
readonly type = 'ssh' as const;
|
||||||
private sftp: SFTPWrapper;
|
private sftp: SFTPWrapper;
|
||||||
|
|
||||||
|
|
@ -27,72 +30,134 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(filePath: string): Promise<boolean> {
|
async exists(filePath: string): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
try {
|
||||||
this.sftp.stat(filePath, (err) => {
|
await this.stat(filePath);
|
||||||
resolve(!err);
|
return true;
|
||||||
});
|
} catch (error) {
|
||||||
});
|
if (this.isNotFoundError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For transient SFTP failures (e.g. code=4), avoid false negatives.
|
||||||
|
if (this.isRetryableError(error)) {
|
||||||
|
const code = this.getErrorCode(error);
|
||||||
|
logger.debug(
|
||||||
|
`exists(${filePath}) got retryable SFTP error (${String(code)}); treating path as potentially present`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
let lastError: unknown;
|
||||||
this.sftp.readFile(filePath, { encoding }, (err, data) => {
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
||||||
if (err) {
|
try {
|
||||||
reject(err);
|
return await new Promise<string>((resolve, reject) => {
|
||||||
return;
|
this.sftp.readFile(filePath, { encoding }, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(data as unknown as string);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (
|
||||||
|
this.isRetryableError(error) &&
|
||||||
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
||||||
|
) {
|
||||||
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
resolve(data as unknown as string);
|
throw error;
|
||||||
});
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`Failed to read file: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async stat(filePath: string): Promise<FsStatResult> {
|
async stat(filePath: string): Promise<FsStatResult> {
|
||||||
return new Promise((resolve, reject) => {
|
let lastError: unknown;
|
||||||
this.sftp.stat(filePath, (err, stats) => {
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
||||||
if (err) {
|
try {
|
||||||
reject(err);
|
return await new Promise<FsStatResult>((resolve, reject) => {
|
||||||
return;
|
this.sftp.stat(filePath, (err, stats) => {
|
||||||
}
|
if (err) {
|
||||||
// SFTP stats use mode bitmask for file type detection
|
reject(err);
|
||||||
const S_IFMT = 0o170000;
|
return;
|
||||||
const S_IFREG = 0o100000;
|
}
|
||||||
const S_IFDIR = 0o040000;
|
// SFTP stats use mode bitmask for file type detection
|
||||||
const mode = stats.mode;
|
const S_IFMT = 0o170000;
|
||||||
|
const S_IFREG = 0o100000;
|
||||||
|
const S_IFDIR = 0o040000;
|
||||||
|
const mode = stats.mode;
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
mtimeMs: (stats.mtime ?? 0) * 1000,
|
mtimeMs: (stats.mtime ?? 0) * 1000,
|
||||||
// SFTP doesn't provide birth time, use mtime as fallback
|
// SFTP doesn't provide birth time, use mtime as fallback
|
||||||
birthtimeMs: (stats.mtime ?? 0) * 1000,
|
birthtimeMs: (stats.mtime ?? 0) * 1000,
|
||||||
isFile: () => (mode & S_IFMT) === S_IFREG,
|
isFile: () => (mode & S_IFMT) === S_IFREG,
|
||||||
isDirectory: () => (mode & S_IFMT) === S_IFDIR,
|
isDirectory: () => (mode & S_IFMT) === S_IFDIR,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
} catch (error) {
|
||||||
});
|
lastError = error;
|
||||||
|
if (
|
||||||
|
this.isRetryableError(error) &&
|
||||||
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
||||||
|
) {
|
||||||
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`Failed to stat: ${filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readdir(dirPath: string): Promise<FsDirent[]> {
|
async readdir(dirPath: string): Promise<FsDirent[]> {
|
||||||
return new Promise((resolve, reject) => {
|
let lastError: unknown;
|
||||||
this.sftp.readdir(dirPath, (err, list) => {
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
||||||
if (err) {
|
try {
|
||||||
reject(err);
|
return await new Promise<FsDirent[]>((resolve, reject) => {
|
||||||
return;
|
this.sftp.readdir(dirPath, (err, list) => {
|
||||||
}
|
if (err) {
|
||||||
const S_IFMT = 0o170000;
|
reject(err);
|
||||||
const S_IFREG = 0o100000;
|
return;
|
||||||
const S_IFDIR = 0o040000;
|
}
|
||||||
|
const S_IFMT = 0o170000;
|
||||||
|
const S_IFREG = 0o100000;
|
||||||
|
const S_IFDIR = 0o040000;
|
||||||
|
|
||||||
const entries: FsDirent[] = list.map((item) => {
|
const entries: FsDirent[] = [];
|
||||||
const mode = item.attrs.mode;
|
for (const item of list) {
|
||||||
return {
|
const mode = item.attrs.mode;
|
||||||
name: item.filename,
|
entries.push(this.buildDirent(item.filename, mode, S_IFMT, S_IFREG, S_IFDIR));
|
||||||
isFile: () => (mode & S_IFMT) === S_IFREG,
|
}
|
||||||
isDirectory: () => (mode & S_IFMT) === S_IFDIR,
|
resolve(entries);
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
resolve(entries);
|
} catch (error) {
|
||||||
});
|
lastError = error;
|
||||||
});
|
if (
|
||||||
|
this.isRetryableError(error) &&
|
||||||
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
||||||
|
) {
|
||||||
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`Failed to read directory: ${dirPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
createReadStream(filePath: string, opts?: ReadStreamOptions): Readable {
|
createReadStream(filePath: string, opts?: ReadStreamOptions): Readable {
|
||||||
|
|
@ -126,4 +191,53 @@ export class SshFileSystemProvider implements FileSystemProvider {
|
||||||
// Ignore errors during cleanup
|
// Ignore errors during cleanup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorCode(error: unknown): string {
|
||||||
|
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||||
|
const code = (error as { code?: unknown }).code;
|
||||||
|
if (typeof code === 'number') {
|
||||||
|
return String(code);
|
||||||
|
}
|
||||||
|
if (typeof code === 'string') {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isNotFoundError(error: unknown): boolean {
|
||||||
|
const code = this.getErrorCode(error);
|
||||||
|
return code === '2' || code === 'ENOENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRetryableError(error: unknown): boolean {
|
||||||
|
const code = this.getErrorCode(error);
|
||||||
|
return (
|
||||||
|
code === '4' ||
|
||||||
|
code === 'EAGAIN' ||
|
||||||
|
code === 'ECONNRESET' ||
|
||||||
|
code === 'ETIMEDOUT' ||
|
||||||
|
code === 'EPIPE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDirent(
|
||||||
|
filename: string,
|
||||||
|
mode: number,
|
||||||
|
sifmt: number,
|
||||||
|
sifreg: number,
|
||||||
|
sifdir: number
|
||||||
|
): FsDirent {
|
||||||
|
return {
|
||||||
|
name: filename,
|
||||||
|
isFile: () => (mode & sifmt) === sifreg,
|
||||||
|
isDirectory: () => (mode & sifmt) === sifdir,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -228,23 +228,31 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Toggle pin/unpin for a session
|
// Toggle pin/unpin for a session (optimistic update)
|
||||||
togglePinSession: async (sessionId: string) => {
|
togglePinSession: async (sessionId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const projectId = state.selectedProjectId;
|
const projectId = state.selectedProjectId;
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
const isPinned = state.pinnedSessionIds.includes(sessionId);
|
const isPinned = state.pinnedSessionIds.includes(sessionId);
|
||||||
|
const previousPinnedIds = state.pinnedSessionIds;
|
||||||
|
|
||||||
|
// Optimistic: update UI immediately
|
||||||
|
if (isPinned) {
|
||||||
|
set({ pinnedSessionIds: previousPinnedIds.filter((id) => id !== sessionId) });
|
||||||
|
} else {
|
||||||
|
set({ pinnedSessionIds: [sessionId, ...previousPinnedIds] });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
await api.config.unpinSession(projectId, sessionId);
|
await api.config.unpinSession(projectId, sessionId);
|
||||||
set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) });
|
|
||||||
} else {
|
} else {
|
||||||
await api.config.pinSession(projectId, sessionId);
|
await api.config.pinSession(projectId, sessionId);
|
||||||
set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Rollback on failure
|
||||||
|
set({ pinnedSessionIds: previousPinnedIds });
|
||||||
logger.error('togglePinSession error:', error);
|
logger.error('togglePinSession error:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue