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
This commit is contained in:
parent
85f34eb828
commit
777d93f968
3 changed files with 394 additions and 0 deletions
197
src/main/services/infrastructure/ServiceContext.ts
Normal file
197
src/main/services/infrastructure/ServiceContext.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
193
src/main/services/infrastructure/ServiceContextRegistry.ts
Normal file
193
src/main/services/infrastructure/ServiceContextRegistry.ts
Normal file
|
|
@ -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<string, ServiceContext>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue