brainy/src/vfs/PathResolver.ts
David Snelling 401e300ff2 feat: harden plugin system wiring and add developer diagnostics
Fix critical wiring bugs that prevented plugin-provided implementations
from being used at runtime. All CRUD operations, fork/checkout/clear,
batch embedding, neural APIs, and VFS path resolution now properly
dispatch through the plugin registry.

Changes:
- Wire graphIndex to storage for getVerbsBySource() fast path
- Replace instanceof checks with duck-typing (indexIsTypeAware flag)
  so plugin HNSW indexes work in add/update/delete/search
- Add createIndex() shared helper for plugin HNSW factory
- Fix fork/checkout/clear to use plugin factories for metadataIndex,
  graphIndex, and HNSW instead of hardcoding JS constructors
- Add three-tier embedBatch priority: embedBatch > embeddings > WASM
- Skip WASM warmup/eagerEmbeddings when plugin provides embeddings
- Fix PathResolver metadataIndex access (was looking on storage)
- Use global UnifiedCache in SemanticPathResolver
- Wire plugin distance function through neural APIs
- Add diagnostics() method and CLI command for provider inspection
- Add requireProviders() for production fail-fast assertions
- Add init-time provider summary log
- Add plugin developer documentation (docs/PLUGINS.md)
- Export DiagnosticsResult type
2026-02-01 13:03:15 -08:00

591 lines
No EOL
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Path Resolution System with High-Performance Caching
*
* PRODUCTION-READY path resolution for VFS
* Handles millions of paths efficiently with multi-layer caching
*/
import { Brainy } from '../brainy.js'
import { VerbType, NounType } from '../types/graphTypes.js'
import { VFSEntity, VFSError, VFSErrorCode } from './types.js'
import { getGlobalCache } from '../utils/unifiedCache.js'
import { prodLog } from '../utils/logger.js'
/**
* Path cache entry
*/
interface PathCacheEntry {
entityId: string
timestamp: number
hits: number // Track hot paths
}
/**
* High-performance path resolver with intelligent caching
*/
export class PathResolver {
private brain: Brainy
private rootEntityId: string
// Multi-layer cache system
private pathCache: Map<string, PathCacheEntry>
private parentCache: Map<string, Set<string>> // parent ID -> child names
private hotPaths: Set<string> // Frequently accessed paths
// Cache configuration
private readonly maxCacheSize: number
private readonly cacheTTL: number
private readonly hotPathThreshold: number
// Statistics
private cacheHits = 0
private cacheMisses = 0
private metadataIndexHits = 0
private metadataIndexMisses = 0
private graphTraversalFallbacks = 0
// Maintenance timer
private maintenanceTimer: NodeJS.Timeout | null = null
constructor(brain: Brainy, rootEntityId: string, config?: {
maxCacheSize?: number
cacheTTL?: number
hotPathThreshold?: number
}) {
this.brain = brain
this.rootEntityId = rootEntityId
// Initialize caches
this.pathCache = new Map()
this.parentCache = new Map()
this.hotPaths = new Set()
// Configure cache
this.maxCacheSize = config?.maxCacheSize || 100_000
this.cacheTTL = config?.cacheTTL || 5 * 60 * 1000 // 5 minutes
this.hotPathThreshold = config?.hotPathThreshold || 10
// Start cache maintenance
this.startCacheMaintenance()
}
/**
* Resolve a path to an entity ID
* Uses 3-tier caching + MetadataIndexManager for optimal performance
* Works for ALL storage adapters (FileSystem, GCS, S3, Azure, R2, OPFS)
*/
async resolve(path: string, options?: {
followSymlinks?: boolean
cache?: boolean
}): Promise<string> {
// Normalize path
const normalizedPath = this.normalizePath(path)
// Handle root
if (normalizedPath === '/') {
return this.rootEntityId
}
const cacheKey = `vfs:path:${normalizedPath}`
// L1: UnifiedCache (global LRU cache, <1ms, works for ALL adapters)
if (options?.cache !== false) {
const cached = getGlobalCache().getSync(cacheKey)
if (cached) {
this.cacheHits++
return cached
}
}
// L2: Local hot paths cache (warm, <1ms)
if (options?.cache !== false && this.hotPaths.has(normalizedPath)) {
const cached = this.pathCache.get(normalizedPath)
if (cached && this.isCacheValid(cached)) {
this.cacheHits++
cached.hits++
// Also cache in UnifiedCache for cross-instance sharing
getGlobalCache().set(cacheKey, cached.entityId, 'other', 64, 20)
return cached.entityId
}
}
// L2b: Regular local cache
if (options?.cache !== false && this.pathCache.has(normalizedPath)) {
const cached = this.pathCache.get(normalizedPath)!
if (this.isCacheValid(cached)) {
this.cacheHits++
cached.hits++
// Promote to hot path if accessed frequently
if (cached.hits >= this.hotPathThreshold) {
this.hotPaths.add(normalizedPath)
}
// Also cache in UnifiedCache
getGlobalCache().set(cacheKey, cached.entityId, 'other', 64, 20)
return cached.entityId
} else {
// Remove stale entry
this.pathCache.delete(normalizedPath)
}
}
this.cacheMisses++
// L3: MetadataIndexManager query (cold, 5-20ms on GCS, works for ALL adapters)
// Falls back to graph traversal automatically if MetadataIndex unavailable
const entityId = await this.resolveWithMetadataIndex(normalizedPath)
// Cache the result in ALL layers for future hits
if (options?.cache !== false) {
getGlobalCache().set(cacheKey, entityId, 'other', 64, 20)
this.cachePathEntry(normalizedPath, entityId)
}
return entityId
}
/**
* Full path resolution by traversing the graph
*/
private async fullResolve(path: string, options?: {
followSymlinks?: boolean
}): Promise<string> {
const parts = this.splitPath(path)
let currentId = this.rootEntityId
let currentPath = '/'
for (const part of parts) {
if (!part) continue // Skip empty parts
// Find child with matching name
const childId = await this.resolveChild(currentId, part)
if (!childId) {
throw new VFSError(
VFSErrorCode.ENOENT,
`No such file or directory: ${path}`,
path,
'resolve'
)
}
currentPath = this.joinPath(currentPath, part)
currentId = childId
// Cache intermediate paths
this.cachePathEntry(currentPath, currentId)
// Handle symlinks if needed
if (options?.followSymlinks) {
const entity = await this.getEntity(currentId)
if (entity.metadata.vfsType === 'symlink') {
// Resolve symlink target
const target = entity.metadata.attributes?.target
if (target) {
currentId = await this.resolve(target, options)
}
}
}
}
return currentId
}
/**
* Resolve path using MetadataIndexManager (O(log n) direct query)
* Works for ALL storage adapters (FileSystem, GCS, S3, Azure, R2, OPFS)
* Falls back to graph traversal if MetadataIndex unavailable
*/
private async resolveWithMetadataIndex(path: string): Promise<string> {
// Access MetadataIndexManager from brain instance (not storage — metadataIndex lives on Brainy)
const metadataIndex = (this.brain as any).metadataIndex
if (!metadataIndex) {
// MetadataIndex not available, use graph traversal
prodLog.debug(`MetadataIndex not available for ${path}, using graph traversal`)
this.graphTraversalFallbacks++
return await this.fullResolve(path)
}
try {
// Direct O(log n) query to roaring bitmap index
// This queries the 'path' field in VFS entity metadata
const ids = await metadataIndex.getIdsFromChunks('path', path)
if (ids.length === 0) {
this.metadataIndexMisses++
throw new VFSError(
VFSErrorCode.ENOENT,
`No such file or directory: ${path}`,
path,
'resolveWithMetadataIndex'
)
}
this.metadataIndexHits++
return ids[0] // VFS paths are unique, return first match
} catch (error) {
// MetadataIndex query failed (index not built, path not indexed, etc.)
// Fallback to reliable graph traversal
if (error instanceof VFSError) {
throw error // Re-throw ENOENT errors
}
prodLog.debug(`MetadataIndex query failed for ${path}, falling back to graph traversal:`, error)
this.metadataIndexMisses++
this.graphTraversalFallbacks++
return await this.fullResolve(path)
}
}
/**
* Resolve a child entity by name within a parent directory
* Uses proper graph relationships instead of metadata queries
*/
private async resolveChild(parentId: string, name: string): Promise<string | null> {
// Check parent cache first
const cachedChildren = this.parentCache.get(parentId)
if (cachedChildren && cachedChildren.has(name)) {
// Use cached knowledge to quickly find the child
// Still need to verify it exists
}
// Use proper graph traversal to find children
// VFS relationships are now part of the knowledge graph
const relations = await this.brain.getRelations({
from: parentId,
type: VerbType.Contains
})
// PERFORMANCE FIX - Batch fetch all children (eliminates N+1 pattern)
// Before: N sequential get() calls (10 children = 10 × 300ms = 3000ms on GCS)
// After: 1 batch call (10 children = 1 × 300ms = 300ms on GCS)
// 10x improvement for cloud storage (GCS, S3, Azure)
// Same pattern as getChildren() (line 240) - now consistently applied
const childIds = relations.map(r => r.to)
const childrenMap = await this.brain.batchGet(childIds)
// Find the child with matching name
for (const relation of relations) {
const childEntity = childrenMap.get(relation.to)
if (childEntity && childEntity.metadata?.name === name) {
// Update parent cache
if (!this.parentCache.has(parentId)) {
this.parentCache.set(parentId, new Set())
}
this.parentCache.get(parentId)!.add(name)
return childEntity.id
}
}
return null
}
/**
* Get all children of a directory
* Uses proper graph relationships to traverse the tree
*/
async getChildren(dirId: string): Promise<VFSEntity[]> {
// Use O(1) graph relationships (VFS creates these in mkdir/writeFile)
// VFS relationships are now part of the knowledge graph (no special filtering needed)
const relations = await this.brain.getRelations({
from: dirId,
type: VerbType.Contains
})
const validChildren: VFSEntity[] = []
const childNames = new Set<string>()
// Batch fetch all child entities (eliminates N+1 query pattern)
// This is WIRED UP AND USED - no longer a stub!
const childIds = relations.map(r => r.to)
const childrenMap = await this.brain.batchGet(childIds)
// Deduplicate by entity ID to handle duplicate relationship records
// This can occur when multiple Brainy instances create relationships concurrently
// for the same storage path (each instance has its own in-memory GraphAdjacencyIndex).
// The Set lookup is O(1), adding negligible overhead.
const seenEntityIds = new Set<string>()
// Process batched results
for (const relation of relations) {
// Skip if we've already processed this entity
if (seenEntityIds.has(relation.to)) {
continue
}
seenEntityIds.add(relation.to)
const entity = childrenMap.get(relation.to)
if (entity && entity.metadata?.vfsType && entity.metadata?.name) {
validChildren.push(entity as VFSEntity)
childNames.add(entity.metadata.name)
}
}
// Update cache
this.parentCache.set(dirId, childNames)
return validChildren
}
/**
* Create a new path entry (for mkdir/writeFile)
*/
async createPath(path: string, entityId: string): Promise<void> {
const normalizedPath = this.normalizePath(path)
// Cache the new path
this.cachePathEntry(normalizedPath, entityId)
// Update parent cache
const parentPath = this.getParentPath(normalizedPath)
const name = this.getBasename(normalizedPath)
if (parentPath) {
const parentId = await this.resolve(parentPath)
if (!this.parentCache.has(parentId)) {
this.parentCache.set(parentId, new Set())
}
this.parentCache.get(parentId)!.add(name)
}
}
/**
* Invalidate ALL caches
* Call this when switching branches (checkout), clearing data (clear), or forking
* This ensures no stale data from previous branch/state remains in cache
*/
invalidateAllCaches(): void {
// Clear all local caches
this.pathCache.clear()
this.parentCache.clear()
this.hotPaths.clear()
// Clear all VFS entries from UnifiedCache
getGlobalCache().deleteByPrefix('vfs:path:')
// Reset statistics (optional but helpful for debugging)
this.cacheHits = 0
this.cacheMisses = 0
this.metadataIndexHits = 0
this.metadataIndexMisses = 0
this.graphTraversalFallbacks = 0
prodLog.info('[PathResolver] All caches invalidated')
}
/**
* Invalidate cache entries for a path and its children
* FIX: Also invalidates UnifiedCache to prevent stale entity IDs
* This fixes the "Source entity not found" bug after delete+recreate operations
*/
invalidatePath(path: string, recursive = false): void {
const normalizedPath = this.normalizePath(path)
// FIX: Clear parent cache BEFORE deleting from pathCache
// (we need the entityId from the cache entry)
const cached = this.pathCache.get(normalizedPath)
if (cached) {
this.parentCache.delete(cached.entityId)
}
// Remove from local caches
this.pathCache.delete(normalizedPath)
this.hotPaths.delete(normalizedPath)
// CRITICAL FIX: Also invalidate UnifiedCache (global LRU cache)
// This was missing before, causing stale entity IDs to be returned after delete
const cacheKey = `vfs:path:${normalizedPath}`
getGlobalCache().delete(cacheKey)
if (recursive) {
// Remove all paths that start with this path
const prefix = normalizedPath.endsWith('/') ? normalizedPath : normalizedPath + '/'
for (const [cachedPath, entry] of this.pathCache) {
if (cachedPath.startsWith(prefix)) {
this.pathCache.delete(cachedPath)
this.hotPaths.delete(cachedPath)
// Also clear parent cache for this entry
this.parentCache.delete(entry.entityId)
}
}
// CRITICAL FIX: Also invalidate UnifiedCache entries with this prefix
const globalCachePrefix = `vfs:path:${prefix}`
getGlobalCache().deleteByPrefix(globalCachePrefix)
}
}
/**
* Cache a path entry
*/
private cachePathEntry(path: string, entityId: string): void {
// Evict old entries if cache is full
if (this.pathCache.size >= this.maxCacheSize) {
this.evictOldEntries()
}
const existing = this.pathCache.get(path)
this.pathCache.set(path, {
entityId,
timestamp: Date.now(),
hits: existing?.hits || 0
})
}
/**
* Check if a cache entry is still valid
*/
private isCacheValid(entry: PathCacheEntry): boolean {
return (Date.now() - entry.timestamp) < this.cacheTTL
}
/**
* Evict old cache entries (LRU with TTL)
*/
private evictOldEntries(): void {
const now = Date.now()
const entries = Array.from(this.pathCache.entries())
// Sort by least recently used (combination of timestamp and hits)
entries.sort((a, b) => {
const scoreA = a[1].timestamp + (a[1].hits * 60000) // Boost for hits
const scoreB = b[1].timestamp + (b[1].hits * 60000)
return scoreA - scoreB
})
// Remove 10% of cache
const toRemove = Math.floor(this.maxCacheSize * 0.1)
for (let i = 0; i < toRemove && i < entries.length; i++) {
const [path] = entries[i]
this.pathCache.delete(path)
this.hotPaths.delete(path)
}
}
/**
* Start periodic cache maintenance
*/
private startCacheMaintenance(): void {
this.maintenanceTimer = setInterval(() => {
// Clean up expired entries
const now = Date.now()
for (const [path, entry] of this.pathCache) {
if (!this.isCacheValid(entry)) {
this.pathCache.delete(path)
this.hotPaths.delete(path)
}
}
// Log cache statistics (in production, send to monitoring)
const hitRate = this.cacheHits / (this.cacheHits + this.cacheMisses)
if ((this.cacheHits + this.cacheMisses) % 1000 === 0) {
console.log(`[PathResolver] Cache stats: ${Math.round(hitRate * 100)}% hit rate, ${this.pathCache.size} entries, ${this.hotPaths.size} hot paths`)
}
}, 60000) // Every minute
}
/**
* Get entity by ID
*/
private async getEntity(entityId: string): Promise<VFSEntity> {
const entity = await this.brain.get(entityId)
if (!entity) {
throw new VFSError(
VFSErrorCode.ENOENT,
`Entity not found: ${entityId}`,
undefined,
'getEntity'
)
}
return entity as VFSEntity
}
// ============= Path Utilities =============
private normalizePath(path: string): string {
// Remove multiple slashes, trailing slashes (except for root)
let normalized = path.replace(/\/+/g, '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1)
}
return normalized || '/'
}
private splitPath(path: string): string[] {
return this.normalizePath(path).split('/').filter(Boolean)
}
private joinPath(parent: string, child: string): string {
if (parent === '/') return `/${child}`
return `${parent}/${child}`
}
private getParentPath(path: string): string | null {
const normalized = this.normalizePath(path)
if (normalized === '/') return null
const lastSlash = normalized.lastIndexOf('/')
if (lastSlash === 0) return '/'
return normalized.substring(0, lastSlash)
}
private getBasename(path: string): string {
const normalized = this.normalizePath(path)
if (normalized === '/') return ''
const lastSlash = normalized.lastIndexOf('/')
return normalized.substring(lastSlash + 1)
}
/**
* Cleanup resources
*/
cleanup(): void {
if (this.maintenanceTimer) {
clearInterval(this.maintenanceTimer)
this.maintenanceTimer = null
}
this.pathCache.clear()
this.parentCache.clear()
this.hotPaths.clear()
}
/**
* Get cache statistics
* Added MetadataIndexManager metrics
*/
getStats(): {
cacheSize: number
hotPaths: number
hitRate: number
hits: number
misses: number
metadataIndexHits: number
metadataIndexMisses: number
metadataIndexHitRate: number
graphTraversalFallbacks: number
} {
const totalMetadataIndexQueries = this.metadataIndexHits + this.metadataIndexMisses
return {
cacheSize: this.pathCache.size,
hotPaths: this.hotPaths.size,
hitRate: this.cacheHits / (this.cacheHits + this.cacheMisses) || 0,
hits: this.cacheHits,
misses: this.cacheMisses,
metadataIndexHits: this.metadataIndexHits,
metadataIndexMisses: this.metadataIndexMisses,
metadataIndexHitRate: totalMetadataIndexQueries > 0
? this.metadataIndexHits / totalMetadataIndexQueries
: 0,
graphTraversalFallbacks: this.graphTraversalFallbacks
}
}
}