From 777d93f9685292d8853ce5c8e91dbf2db6603d5c Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 00:55:26 +0000 Subject: [PATCH] feat(02-01): create ServiceContext and ServiceContextRegistry - ServiceContext: service bundle for single workspace context - Encapsulates all session-data services (ProjectScanner, SessionParser, SubagentResolver, ChunkBuilder, DataCache, FileWatcher) - Manages service lifecycle (start, stop, dispose) - Proper dependency chain wiring - ServiceContextRegistry: coordinator for all contexts - Manages Map of contexts with active tracking - Enforces local context permanence (cannot be destroyed) - Context switching (stops old watcher, starts new watcher) - Safe disposal of SSH contexts - Updated barrel exports in infrastructure/index.ts --- .../services/infrastructure/ServiceContext.ts | 197 ++++++++++++++++++ .../infrastructure/ServiceContextRegistry.ts | 193 +++++++++++++++++ src/main/services/infrastructure/index.ts | 4 + 3 files changed, 394 insertions(+) create mode 100644 src/main/services/infrastructure/ServiceContext.ts create mode 100644 src/main/services/infrastructure/ServiceContextRegistry.ts diff --git a/src/main/services/infrastructure/ServiceContext.ts b/src/main/services/infrastructure/ServiceContext.ts new file mode 100644 index 00000000..eefef501 --- /dev/null +++ b/src/main/services/infrastructure/ServiceContext.ts @@ -0,0 +1,197 @@ +/** + * ServiceContext - Bundle of session-data services for a single workspace context. + * + * Responsibilities: + * - Encapsulate all session-data services (ProjectScanner, SessionParser, etc.) + * - Manage service lifecycle (creation, start, stop, dispose) + * - Provide isolation between local and SSH contexts + * + * Each ServiceContext represents a complete service stack for one workspace: + * - Local context: ~/.claude/projects/ on local filesystem + * - SSH context: remote ~/.claude/projects/ over SFTP + */ + +import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; +import { ProjectScanner } from '@main/services/discovery/ProjectScanner'; +import { SessionParser } from '@main/services/parsing/SessionParser'; +import { SubagentResolver } from '@main/services/discovery/SubagentResolver'; +import { + CACHE_CLEANUP_INTERVAL_MINUTES, + CACHE_TTL_MINUTES, + MAX_CACHE_SESSIONS, +} from '@shared/constants'; +import { createLogger } from '@shared/utils/logger'; + +import { DataCache } from './DataCache'; +import { FileWatcher } from './FileWatcher'; + +import type { FileSystemProvider } from './FileSystemProvider'; + +const logger = createLogger('Infrastructure:ServiceContext'); + +/** + * Configuration for creating a ServiceContext. + */ +export interface ServiceContextConfig { + /** Unique identifier (e.g., 'local', 'ssh-myserver') */ + id: string; + /** Context type */ + type: 'local' | 'ssh'; + /** Filesystem provider for this context */ + fsProvider: FileSystemProvider; + /** Projects directory path (defaults to ~/.claude/projects) */ + projectsDir?: string; + /** Todos directory path (defaults to ~/.claude/todos) */ + todosDir?: string; +} + +/** + * ServiceContext - Isolated service bundle for one workspace context. + * + * Contains all session-data services configured for a specific workspace + * (local or SSH). Services share the same FileSystemProvider and are + * properly wired with dependencies. + * + * Lifecycle: + * - Create: new ServiceContext(config) + * - Start: context.start() — activates file watching and cache cleanup + * - Pause: context.stopFileWatcher() — on context switch + * - Resume: context.startFileWatcher() — on context switch back + * - Destroy: context.dispose() — cleans up all resources + */ +export class ServiceContext { + /** Context identifier */ + readonly id: string; + /** Context type */ + readonly type: 'local' | 'ssh'; + /** Filesystem provider */ + readonly fsProvider: FileSystemProvider; + + // Service instances + readonly projectScanner: ProjectScanner; + readonly sessionParser: SessionParser; + readonly subagentResolver: SubagentResolver; + readonly chunkBuilder: ChunkBuilder; + readonly dataCache: DataCache; + readonly fileWatcher: FileWatcher; + + private cleanupInterval: NodeJS.Timeout | null = null; + private disposed = false; + + constructor(config: ServiceContextConfig) { + this.id = config.id; + this.type = config.type; + this.fsProvider = config.fsProvider; + + logger.info(`Creating ServiceContext: ${config.id} (${config.type})`); + + // Create services in dependency order + const disableCache = process.env.CLAUDE_CONTEXT_DISABLE_CACHE === '1'; + + // 1. ProjectScanner - no dependencies (uses fsProvider directly) + this.projectScanner = new ProjectScanner( + config.projectsDir, + config.todosDir, + config.fsProvider + ); + + // 2. SessionParser - depends on ProjectScanner + this.sessionParser = new SessionParser(this.projectScanner); + + // 3. SubagentResolver - depends on ProjectScanner + this.subagentResolver = new SubagentResolver(this.projectScanner); + + // 4. ChunkBuilder - no dependencies + this.chunkBuilder = new ChunkBuilder(); + + // 5. DataCache - standalone service + this.dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache); + + // 6. FileWatcher - uses fsProvider and dataCache + this.fileWatcher = new FileWatcher( + this.dataCache, + config.projectsDir, + config.todosDir, + config.fsProvider + ); + + logger.info(`ServiceContext created: ${config.id}`); + } + + /** + * Starts the file watcher and cache cleanup. + * Call this after creating the context to activate monitoring. + */ + start(): void { + if (this.disposed) { + logger.error(`Cannot start disposed context: ${this.id}`); + return; + } + + logger.info(`Starting ServiceContext: ${this.id}`); + + // Start file watcher + this.fileWatcher.start(); + + // Start cache auto-cleanup + this.cleanupInterval = this.dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES); + } + + /** + * Stops the file watcher (for pausing on context switch). + * Does not dispose resources - can be resumed with startFileWatcher(). + */ + stopFileWatcher(): void { + logger.info(`Stopping FileWatcher for context: ${this.id}`); + this.fileWatcher.stop(); + } + + /** + * Starts the file watcher (for resuming after context switch). + */ + startFileWatcher(): void { + if (this.disposed) { + logger.error(`Cannot start FileWatcher on disposed context: ${this.id}`); + return; + } + + logger.info(`Starting FileWatcher for context: ${this.id}`); + this.fileWatcher.start(); + } + + /** + * Disposes all resources. + * After calling dispose(), this context cannot be reused. + */ + dispose(): void { + if (this.disposed) { + logger.warn(`ServiceContext already disposed: ${this.id}`); + return; + } + + logger.info(`Disposing ServiceContext: ${this.id}`); + + // Stop and dispose FileWatcher + this.fileWatcher.dispose(); + + // Dispose DataCache + this.dataCache.dispose(); + + // Clear cleanup interval + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + + this.disposed = true; + + logger.info(`ServiceContext disposed: ${this.id}`); + } + + /** + * Returns whether this context has been disposed. + */ + isDisposed(): boolean { + return this.disposed; + } +} diff --git a/src/main/services/infrastructure/ServiceContextRegistry.ts b/src/main/services/infrastructure/ServiceContextRegistry.ts new file mode 100644 index 00000000..4ae1e79a --- /dev/null +++ b/src/main/services/infrastructure/ServiceContextRegistry.ts @@ -0,0 +1,193 @@ +/** + * ServiceContextRegistry - Manages the Map of ServiceContext instances. + * + * Responsibilities: + * - Register and track all ServiceContext instances (local + SSH) + * - Track active context ID + * - Handle context switching (stop old watcher, start new watcher) + * - Enforce lifecycle rules (local context cannot be destroyed) + * - Provide safe disposal of contexts + * + * Lifecycle: + * - App startup: registry created, local context registered + * - SSH connect: new SSH context registered + * - Context switch: switch() stops old watcher, starts new watcher + * - SSH disconnect: destroy() removes SSH context + * - App shutdown: dispose() cleans up all contexts + */ + +import { createLogger } from '@shared/utils/logger'; + +import { ServiceContext } from './ServiceContext'; + +const logger = createLogger('Infrastructure:ServiceContextRegistry'); + +/** + * ServiceContextRegistry - Coordinator for all service contexts. + * + * Manages a Map of ServiceContext instances and tracks which one is active. + * Enforces the rule that the 'local' context is permanent and cannot be destroyed. + */ +export class ServiceContextRegistry { + private contexts = new Map(); + private activeContextId: string = 'local'; + + /** + * Creates a new ServiceContextRegistry. + * Does NOT create the local context - that must be done externally + * where mainWindow and NotificationManager wiring exists. + */ + constructor() { + logger.info('ServiceContextRegistry created'); + } + + /** + * Registers a new context. + * @throws Error if a context with the same ID already exists + */ + registerContext(context: ServiceContext): void { + if (this.contexts.has(context.id)) { + throw new Error(`Context already registered: ${context.id}`); + } + + this.contexts.set(context.id, context); + logger.info(`Context registered: ${context.id} (${context.type})`); + } + + /** + * Gets the active ServiceContext. + * @throws Error if active context not found (should never happen) + */ + getActive(): ServiceContext { + const context = this.contexts.get(this.activeContextId); + if (!context) { + throw new Error(`Active context not found: ${this.activeContextId}`); + } + return context; + } + + /** + * Gets a context by ID. + * @returns ServiceContext or undefined if not found + */ + get(contextId: string): ServiceContext | undefined { + return this.contexts.get(contextId); + } + + /** + * Checks if a context exists. + */ + has(contextId: string): boolean { + return this.contexts.has(contextId); + } + + /** + * Switches to a different context. + * Stops the file watcher on the previous context and starts it on the new one. + * + * @param contextId - ID of context to switch to + * @returns Object containing previous and current contexts for IPC re-init + * @throws Error if target context not found + */ + switch(contextId: string): { previous: ServiceContext; current: ServiceContext } { + if (!this.contexts.has(contextId)) { + throw new Error(`Cannot switch to unknown context: ${contextId}`); + } + + const previous = this.getActive(); + const current = this.contexts.get(contextId)!; + + if (previous.id === current.id) { + logger.info(`Already on context: ${contextId}`); + return { previous, current }; + } + + logger.info(`Switching context: ${previous.id} → ${current.id}`); + + // Stop file watcher on previous context (pause, don't dispose) + previous.stopFileWatcher(); + + // Update active context + this.activeContextId = contextId; + + // Start file watcher on new context + current.startFileWatcher(); + + logger.info(`Context switched: ${current.id} is now active`); + + return { previous, current }; + } + + /** + * Destroys a context and removes it from the registry. + * If the destroyed context was active, switches to 'local'. + * + * @param contextId - ID of context to destroy + * @throws Error if attempting to destroy the 'local' context + * @throws Error if context not found + */ + destroy(contextId: string): void { + if (contextId === 'local') { + throw new Error('Cannot destroy local context'); + } + + const context = this.contexts.get(contextId); + if (!context) { + throw new Error(`Context not found: ${contextId}`); + } + + logger.info(`Destroying context: ${contextId}`); + + // Dispose the context + context.dispose(); + + // Remove from map + this.contexts.delete(contextId); + + // If this was the active context, switch to local + if (this.activeContextId === contextId) { + logger.info('Destroyed context was active, switching to local'); + this.activeContextId = 'local'; + const local = this.contexts.get('local'); + if (local) { + local.startFileWatcher(); + } + } + + logger.info(`Context destroyed: ${contextId}`); + } + + /** + * Lists all registered contexts. + * @returns Array of context metadata + */ + list(): Array<{ id: string; type: 'local' | 'ssh' }> { + return Array.from(this.contexts.values()).map((context) => ({ + id: context.id, + type: context.type, + })); + } + + /** + * Gets the active context ID. + */ + getActiveContextId(): string { + return this.activeContextId; + } + + /** + * Disposes ALL contexts (including local). + * Used only on app shutdown. + */ + dispose(): void { + logger.info('Disposing ServiceContextRegistry and all contexts'); + + for (const context of this.contexts.values()) { + context.dispose(); + } + + this.contexts.clear(); + + logger.info('ServiceContextRegistry disposed'); + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts index 946f6a23..bdc1d24a 100644 --- a/src/main/services/infrastructure/index.ts +++ b/src/main/services/infrastructure/index.ts @@ -11,6 +11,8 @@ * - LocalFileSystemProvider: Local fs implementation * - SshFileSystemProvider: SSH/SFTP implementation * - SshConnectionManager: SSH connection lifecycle + * - ServiceContext: Service bundle for a single workspace context + * - ServiceContextRegistry: Registry coordinator for all contexts */ export * from './ConfigManager'; @@ -19,6 +21,8 @@ export type * from './FileSystemProvider'; export * from './FileWatcher'; export * from './LocalFileSystemProvider'; export * from './NotificationManager'; +export * from './ServiceContext'; +export * from './ServiceContextRegistry'; export * from './SshConfigParser'; export * from './SshConnectionManager'; export * from './SshFileSystemProvider';