/** * ProjectPathResolver - Resolves encoded project IDs to canonical filesystem paths. * * Resolution order: * 1) cwd hint (if provided and absolute) * 2) cwd extracted from session JSONL files (authoritative) * 3) decodePath(projectId) fallback (lossy, best-effort) * * Results are memoized per projectId and can be invalidated by file watcher events. */ import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';import { extractCwd } from '@main/utils/jsonl';import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';import { createLogger } from '@shared/utils/logger';import * as path from 'path'; import { subprojectRegistry } from './SubprojectRegistry'; import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; const logger = createLogger('Discovery:ProjectPathResolver'); interface ResolveProjectPathOptions { cwdHint?: string; sessionPaths?: string[]; forceRefresh?: boolean;} export class ProjectPathResolver { private readonly projectsDir: string; private readonly fsProvider: FileSystemProvider; private readonly projectPathCache = new Map<string, string>(); constructor(projectsDir?: string, fsProvider?: FileSystemProvider) { this.projectsDir = projectsDir ?? getProjectsBasePath(); this.fsProvider = fsProvider ?? new LocalFileSystemProvider(); } /** * Resolve a project ID to a canonical path. */ async resolveProjectPath( projectId: string, options?: ResolveProjectPathOptions ): Promise<string> { const opts = options ?? {}; // Short-circuit for composite IDs: use the registry's cwd directly const registryCwd = subprojectRegistry.getCwd(projectId); if (registryCwd) { this.projectPathCache.set(projectId, registryCwd); return registryCwd; } if (!opts.forceRefresh) { const cached = this.projectPathCache.get(projectId); if (cached) { return cached; } } const cwdHint = opts.cwdHint?.trim(); if (cwdHint && path.isAbsolute(cwdHint)) { this.projectPathCache.set(projectId, cwdHint); return cwdHint; } const sessionPaths = opts.sessionPaths?.length ? opts.sessionPaths : await this.listSessionPaths(projectId); // 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 { const cwd = await extractCwd(sessionPath, this.fsProvider); if (cwd && path.isAbsolute(cwd)) { this.projectPathCache.set(projectId, cwd); return cwd; } } catch { // Ignore unreadable or malformed files and continue to next candidate. } } const decoded = decodePath(extractBaseDir(projectId)); this.projectPathCache.set(projectId, decoded); return decoded; } /** * Invalidate a single project's cached path. */ invalidateProject(projectId: string): void { this.projectPathCache.delete(projectId); } /** * Clear all cached project paths. */ clear(): void { this.projectPathCache.clear(); } private async listSessionPaths(projectId: string): Promise<string[]> { const projectDir = path.join(this.projectsDir, extractBaseDir(projectId)); if (!(await this.fsProvider.exists(projectDir))) { return []; } try { const entries = await this.fsProvider.readdir(projectDir); return entries .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) .map((entry) => path.join(projectDir, entry.name)); } catch (error) { logger.error(`Failed to read session files for ${projectId}:`, error); return []; } }} export const projectPathResolver = new ProjectPathResolver();
/**
* ProjectPathResolver - Resolves encoded project IDs to canonical filesystem paths.
*
* Resolution order:
* 1) cwd hint (if provided and absolute)
* 2) cwd extracted from session JSONL files (authoritative)
* 3) decodePath(projectId) fallback (lossy, best-effort)
*
* Results are memoized per projectId and can be invalidated by file watcher events.
*/
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
import { extractCwd } from '@main/utils/jsonl';
import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:ProjectPathResolver');
interface ResolveProjectPathOptions {
cwdHint?: string;
sessionPaths?: string[];
forceRefresh?: boolean;
}
export class ProjectPathResolver {
private readonly projectsDir: string;
private readonly fsProvider: FileSystemProvider;
private readonly projectPathCache = new Map<string, string>();
constructor(projectsDir?: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
* Resolve a project ID to a canonical path.
*/
async resolveProjectPath(
projectId: string,
options?: ResolveProjectPathOptions
): Promise<string> {
const opts = options ?? {};
// Short-circuit for composite IDs: use the registry's cwd directly
const registryCwd = subprojectRegistry.getCwd(projectId);
if (registryCwd) {
this.projectPathCache.set(projectId, registryCwd);
return registryCwd;
}
if (!opts.forceRefresh) {
const cached = this.projectPathCache.get(projectId);
if (cached) {
return cached;
}
}
const cwdHint = opts.cwdHint?.trim();
if (cwdHint && path.isAbsolute(cwdHint)) {
this.projectPathCache.set(projectId, cwdHint);
return cwdHint;
}
const sessionPaths = opts.sessionPaths?.length
? opts.sessionPaths
: await this.listSessionPaths(projectId);
// 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 {
const cwd = await extractCwd(sessionPath, this.fsProvider);
if (cwd && path.isAbsolute(cwd)) {
this.projectPathCache.set(projectId, cwd);
return cwd;
}
} catch {
// Ignore unreadable or malformed files and continue to next candidate.
}
}
const decoded = decodePath(extractBaseDir(projectId));
this.projectPathCache.set(projectId, decoded);
return decoded;
}
/**
* Invalidate a single project's cached path.
*/
invalidateProject(projectId: string): void {
this.projectPathCache.delete(projectId);
}
/**
* Clear all cached project paths.
*/
clear(): void {
this.projectPathCache.clear();
}
private async listSessionPaths(projectId: string): Promise<string[]> {
const projectDir = path.join(this.projectsDir, extractBaseDir(projectId));
if (!(await this.fsProvider.exists(projectDir))) {
return [];
}
try {
const entries = await this.fsProvider.readdir(projectDir);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.map((entry) => path.join(projectDir, entry.name));
} catch (error) {
logger.error(`Failed to read session files for ${projectId}:`, error);
return [];
}
}
}
export const projectPathResolver = new ProjectPathResolver();