import { DEFAULT_COMPOSE_SCAN_CONCURRENCY } from "../constants.js";
// src/services/compose-discovery.ts
import type { HostConfig } from "../types.js";
import { logError } from "../utils/errors.js";
import { getCurrentTimestamp } from "../utils/time.js";
import type { CachedProject, ComposeProjectCache } from "./compose-cache.js";
import type { ComposeScanner } from "./compose-scanner.js";
import type { IComposeProjectLister } from "./interfaces.js";
const DEFAULT_SEARCH_PATHS = ["/compose", "/mnt/cache/compose", "/mnt/cache/code"];
export class ComposeDiscovery {
/**
* In-flight filesystem scan promises keyed by host name.
* Coalesces concurrent scan requests for the same host into a single scan,
* preventing redundant filesystem operations (200-1000ms each).
*/
private inflightScans = new Map<string, Promise<Array<{ name: string; path: string }>>>();
constructor(
private projectLister: IComposeProjectLister,
public cache: ComposeProjectCache, // Public for cache invalidation in handlers
private scanner: ComposeScanner
) {}
private getSearchPaths(host: HostConfig, cachedPaths: string[]): string[] {
const paths = new Set<string>();
// Add default paths
for (const p of DEFAULT_SEARCH_PATHS) {
paths.add(p);
}
// Add cached paths
for (const p of cachedPaths) {
paths.add(p);
}
// Add user-configured paths
if (host.composeSearchPaths) {
for (const p of host.composeSearchPaths) {
paths.add(p);
}
}
return Array.from(paths);
}
private async discoverFromDockerLs(
host: HostConfig,
projectName: string
): Promise<CachedProject | null> {
try {
const projects = await this.projectLister.listComposeProjects(host);
const found = projects.find((p) => p.name === projectName);
if (found && found.configFiles.length > 0) {
return {
path: found.configFiles[0],
name: projectName,
discoveredFrom: "docker-ls",
lastSeen: getCurrentTimestamp(),
};
}
} catch (error) {
logError(error as Error, {
operation: "discoverFromDockerLs",
metadata: { host: host.name, project: projectName },
});
}
return null;
}
/**
* Scan a host's filesystem for compose projects, coalescing concurrent requests.
* If a scan is already in flight for this host, returns the existing promise.
*
* @param host - Host to scan
* @returns Array of discovered projects with name and path
*/
private async scanHostFilesystem(
host: HostConfig
): Promise<Array<{ name: string; path: string }>> {
const existing = this.inflightScans.get(host.name);
if (existing) {
return existing;
}
const scanPromise = (async () => {
try {
const cacheData = await this.cache.load(host.name);
const searchPaths = this.getSearchPaths(host, cacheData.searchPaths);
const hostWithSearchPaths: HostConfig = {
...host,
composeSearchPaths: searchPaths,
};
const files = await this.scanner.findComposeFiles(hostWithSearchPaths);
const nameMap = await this.scanner.batchParseComposeNames(
host,
files,
DEFAULT_COMPOSE_SCAN_CONCURRENCY
);
return files.map((file) => {
const dirName = this.scanner.extractProjectName(file);
const explicitName = nameMap.get(file);
const name = explicitName ?? dirName;
return { name, path: file };
});
} finally {
this.inflightScans.delete(host.name);
}
})();
this.inflightScans.set(host.name, scanPromise);
return scanPromise;
}
private async discoverFromFilesystem(
host: HostConfig,
projectName: string
): Promise<CachedProject | null> {
try {
const projects = await this.scanHostFilesystem(host);
// Cache all discovered projects to avoid future scans
for (const project of projects) {
await this.cache.updateProject(host.name, project.name, {
path: project.path,
name: project.name,
discoveredFrom: "scan",
lastSeen: getCurrentTimestamp(),
});
}
const found = projects.find((p) => p.name === projectName);
if (found) {
return {
path: found.path,
name: found.name,
discoveredFrom: "scan",
lastSeen: getCurrentTimestamp(),
};
}
} catch (error) {
logError(error as Error, {
operation: "discoverFromFilesystem",
metadata: { host: host.name, project: projectName },
});
}
return null;
}
/**
* Resolve compose file path for a project
* Strategy:
* 1. Check cache (trust it - lazy invalidation at handler level)
* 2. Check docker compose ls (running projects) - FAST
* 3. Scan filesystem if not found - SLOWER
* 4. Error if not found
*/
async resolveProjectPath(host: HostConfig, projectName: string): Promise<string> {
// Step 1: Check cache (no validation - lazy invalidation)
const cached = await this.cache.getProject(host.name, projectName);
if (cached) {
return cached.path;
}
// Step 2: Try docker ls first (fast, authoritative for running projects)
const dockerLsResult = await this.discoverFromDockerLs(host, projectName);
if (dockerLsResult) {
await this.cache.updateProject(host.name, projectName, dockerLsResult);
return dockerLsResult.path;
}
// Step 3: Fallback to filesystem scan (slower, but finds stopped projects)
const scanResult = await this.discoverFromFilesystem(host, projectName);
if (scanResult) {
await this.cache.updateProject(host.name, projectName, scanResult);
return scanResult.path;
}
// Step 4: Not found
const cacheData = await this.cache.load(host.name);
const searchPaths =
cacheData.searchPaths.length > 0 ? cacheData.searchPaths : DEFAULT_SEARCH_PATHS;
throw new Error(
`Project '${projectName}' not found on host '${host.name}'\nSearched locations: ${searchPaths.join(", ")}\nTip: Provide compose_file parameter if project is in a different location`
);
}
/**
* Warm cache by discovering all projects on a host in the background.
* This mitigates cold-start penalty (200-1000ms) on first compose operation.
* Call this on server startup or after cache invalidation.
*
* @param host - Host to warm cache for
*/
async warmCache(host: HostConfig): Promise<void> {
// Phase 1: Discover from docker ls (fast, finds running projects)
try {
const runningProjects = await this.projectLister.listComposeProjects(host);
// Cache all running projects
for (const project of runningProjects) {
if (project.configFiles.length > 0) {
await this.cache.updateProject(host.name, project.name, {
path: project.configFiles[0],
name: project.name,
discoveredFrom: "docker-ls",
lastSeen: getCurrentTimestamp(),
});
}
}
} catch (error) {
// Non-fatal: docker-ls failure should not prevent filesystem scan
logError(error as Error, {
operation: "warmCache:docker-ls",
metadata: { host: host.name },
});
}
// Phase 2: Discover from filesystem (slower, finds stopped projects)
try {
const cacheData = await this.cache.load(host.name);
const searchPaths = this.getSearchPaths(host, cacheData.searchPaths);
const hostWithSearchPaths: HostConfig = { ...host, composeSearchPaths: searchPaths };
const files = await this.scanner.findComposeFiles(hostWithSearchPaths);
const nameMap = await this.scanner.batchParseComposeNames(
host,
files,
DEFAULT_COMPOSE_SCAN_CONCURRENCY
);
const projects = files.map((file) => {
const dirName = this.scanner.extractProjectName(file);
const explicitName = nameMap.get(file);
const name = explicitName ?? dirName;
return { name, path: file };
});
// Cache all discovered projects
for (const project of projects) {
await this.cache.updateProject(host.name, project.name, {
path: project.path,
name: project.name,
discoveredFrom: "scan",
lastSeen: getCurrentTimestamp(),
});
}
} catch (error) {
// Non-fatal: filesystem scan failure is best-effort
logError(error as Error, {
operation: "warmCache:filesystem-scan",
metadata: { host: host.name },
});
}
}
}