watch
Automatically revalidate contracts by monitoring project files for changes. Manage watching with start, stop, status, and poll actions.
Instructions
Watch project files for changes and auto-revalidate contracts. Actions: start (begin watching), stop (end watching), status (check state), poll (get pending events).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| projectDir | Yes | Root directory with .trace-mcp config | |
| action | No | Watch action (default: start) |
Implementation Reference
- src/index.ts:602-691 (handler)Main handler for the 'watch' tool. Parses WatchInput, loads project, gets watcher, and dispatches to start/stop/status/poll actions.
case 'watch': { const input = WatchInput.parse(args); const action = input.action || 'start'; log(`Watch action: ${action} for ${input.projectDir}`); const project = loadProject(input.projectDir); if (!project.exists()) { throw new Error(`No trace project found at ${input.projectDir}. Run init_project first.`); } const watcher = getWatcher(project); switch (action) { case 'start': { // Collect events during startup const events: WatchEvent[] = []; const eventHandler = (event: WatchEvent) => events.push(event); watcher.on('watch-event', eventHandler); await watcher.start(); // Wait a moment for initial validation await new Promise(resolve => setTimeout(resolve, 100)); watcher.off('watch-event', eventHandler); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'started', projectDir: input.projectDir, status: watcher.getStatus(), events, }, null, 2), }], }; } case 'stop': { await stopWatcher(project); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'stopped', projectDir: input.projectDir, }, null, 2), }], }; } case 'status': { return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'status', projectDir: input.projectDir, status: watcher.getStatus(), activeWatchers: listActiveWatchers(), }, null, 2), }], }; } case 'poll': { // For polling, collect recent events const status = watcher.getStatus(); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'poll', projectDir: input.projectDir, status, lastResult: status.lastResult, }, null, 2), }], }; } default: throw new Error(`Unknown watch action: ${action}`); } } - src/index.ts:104-107 (schema)Zod schema for 'watch' tool input: projectDir (string) and action (enum: start/stop/status/poll).
const WatchInput = z.object({ projectDir: z.string().describe('Root directory with .trace-mcp config'), action: z.enum(['start', 'stop', 'status', 'poll']).default('start').describe('Watch action to perform'), }); - src/index.ts:252-263 (registration)Registration of the 'watch' tool in the list of tools, with name, description, and inputSchema.
{ name: 'watch', description: 'Watch project files for changes and auto-revalidate contracts. Actions: start (begin watching), stop (end watching), status (check state), poll (get pending events).', inputSchema: { type: 'object', properties: { projectDir: { type: 'string', description: 'Root directory with .trace-mcp config' }, action: { type: 'string', enum: ['start', 'stop', 'status', 'poll'], description: 'Watch action (default: start)' }, }, required: ['projectDir'], }, }, - src/watch/watcher.ts:50-331 (handler)TraceWatcher class - the core watch logic: file watching via chokidar, debounced revalidation, event emission, and singleton registry (getWatcher/stopWatcher/listActiveWatchers).
export class TraceWatcher extends EventEmitter { private project: TraceProject; private cache: SchemaCache; private producerWatcher: FSWatcher | null = null; private consumerWatcher: FSWatcher | null = null; private debounceTimer: NodeJS.Timeout | null = null; private pendingChanges: Map<string, FileChangeData> = new Map(); private isRunning = false; private lastResult: TraceResult | null = null; constructor(project: TraceProject) { super(); this.project = project; this.cache = new SchemaCache(project); } // -------------------------------------------------------------------------- // Lifecycle // -------------------------------------------------------------------------- /** * Start watching files */ async start(): Promise<void> { if (this.isRunning) { throw new Error('Watcher is already running'); } const config = this.project.config; this.isRunning = true; // Build glob patterns for producer const producerPatterns = config.producer.include.map( p => join(this.project.producerPath, p) ); const producerIgnore = config.producer.exclude.map( p => join(this.project.producerPath, p) ); // Build glob patterns for consumer const consumerPatterns = config.consumer.include.map( p => join(this.project.consumerPath, p) ); const consumerIgnore = config.consumer.exclude.map( p => join(this.project.consumerPath, p) ); // Start producer watcher this.producerWatcher = chokidar.watch(producerPatterns, { ignored: producerIgnore, persistent: true, ignoreInitial: true, }); this.producerWatcher.on('add', (path: string) => this.onFileChange(path, 'producer', 'add')); this.producerWatcher.on('change', (path: string) => this.onFileChange(path, 'producer', 'change')); this.producerWatcher.on('unlink', (path: string) => this.onFileChange(path, 'producer', 'unlink')); this.producerWatcher.on('error', (err: unknown) => this.onError(err instanceof Error ? err : new Error(String(err)))); // Start consumer watcher this.consumerWatcher = chokidar.watch(consumerPatterns, { ignored: consumerIgnore, persistent: true, ignoreInitial: true, }); this.consumerWatcher.on('add', (path: string) => this.onFileChange(path, 'consumer', 'add')); this.consumerWatcher.on('change', (path: string) => this.onFileChange(path, 'consumer', 'change')); this.consumerWatcher.on('unlink', (path: string) => this.onFileChange(path, 'consumer', 'unlink')); this.consumerWatcher.on('error', (err: unknown) => this.onError(err instanceof Error ? err : new Error(String(err)))); // Initial validation await this.runValidation('initial'); this.emitEvent('ready', { producerPath: this.project.producerPath, consumerPath: this.project.consumerPath, }); } /** * Stop watching files */ async stop(): Promise<void> { if (!this.isRunning) return; if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; } if (this.producerWatcher) { await this.producerWatcher.close(); this.producerWatcher = null; } if (this.consumerWatcher) { await this.consumerWatcher.close(); this.consumerWatcher = null; } this.isRunning = false; this.emitEvent('stopped', {}); } // -------------------------------------------------------------------------- // Event Handlers // -------------------------------------------------------------------------- private onFileChange( filePath: string, side: 'producer' | 'consumer', changeType: 'add' | 'change' | 'unlink' ): void { const relPath = relative(this.project.rootDir, filePath); this.emitEvent('file_changed', { file: relPath, side, changeType, } as FileChangeData); // Accumulate changes this.pendingChanges.set(filePath, { file: relPath, side, changeType }); // Invalidate cache for this file this.cache.invalidateFiles([filePath]); // Debounce revalidation if (this.debounceTimer) { clearTimeout(this.debounceTimer); } const debounceMs = this.project.config.options.debounceMs ?? 300; this.debounceTimer = setTimeout(() => { this.processPendingChanges(); }, debounceMs); } private onError(error: Error): void { this.emitEvent('error', { message: error.message, stack: error.stack, }); } // -------------------------------------------------------------------------- // Validation // -------------------------------------------------------------------------- private async processPendingChanges(): Promise<void> { const changes = Array.from(this.pendingChanges.values()); this.pendingChanges.clear(); if (changes.length === 0) return; const triggeredBy = changes.map(c => c.file).join(', '); await this.runValidation(triggeredBy); } private async runValidation(triggeredBy: string): Promise<void> { this.emitEvent('revalidating', { triggeredBy }); try { // Extract and trace using configured languages const producers = await extractProducerSchemas({ rootDir: this.project.producerPath, language: this.project.config.producer.language, include: this.project.config.producer.include, exclude: this.project.config.producer.exclude, }); const consumers = await traceConsumerUsage({ rootDir: this.project.consumerPath, language: this.project.config.consumer.language, include: this.project.config.consumer.include, exclude: this.project.config.consumer.exclude, }); // Compare const result = compareSchemas(producers, consumers, { strict: this.project.config.options.strict, direction: this.project.config.options.direction, }); this.lastResult = result; this.emitEvent('result', { result, triggeredBy, cached: false, } as ResultData); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emitEvent('error', { message, triggeredBy }); } } // -------------------------------------------------------------------------- // Utility // -------------------------------------------------------------------------- private emitEvent(type: WatchEventType, data: unknown): void { const event: WatchEvent = { type, timestamp: new Date().toISOString(), data, }; this.emit('watch-event', event); } /** * Get the last validation result */ getLastResult(): TraceResult | null { return this.lastResult; } /** * Get current watcher status */ getStatus(): { running: boolean; lastResult: TraceResult | null; pendingChanges: number; } { return { running: this.isRunning, lastResult: this.lastResult, pendingChanges: this.pendingChanges.size, }; } /** * Force a revalidation */ async forceRevalidation(): Promise<TraceResult> { await this.runValidation('manual'); return this.lastResult!; } } // ============================================================================ // Singleton Registry // ============================================================================ // Track active watchers by project path const activeWatchers = new Map<string, TraceWatcher>(); /** * Get or create a watcher for a project */ export function getWatcher(project: TraceProject): TraceWatcher { const key = project.rootDir; if (!activeWatchers.has(key)) { activeWatchers.set(key, new TraceWatcher(project)); } return activeWatchers.get(key)!; } /** * Stop and remove a watcher */ export async function stopWatcher(project: TraceProject): Promise<void> { const key = project.rootDir; const watcher = activeWatchers.get(key); if (watcher) { await watcher.stop(); activeWatchers.delete(key); } } /** * List all active watchers */ export function listActiveWatchers(): string[] { return Array.from(activeWatchers.keys()); } - src/watch/cache.ts:51-317 (helper)SchemaCache class - checksum-based caching for extracted schemas and traced usage, used by TraceWatcher to avoid redundant revalidation.
export class SchemaCache { private project: TraceProject; constructor(project: TraceProject) { this.project = project; } // -------------------------------------------------------------------------- // Producer Cache // -------------------------------------------------------------------------- /** * Get cached producer schemas if still valid */ getProducerSchemas(files: string[]): ProducerSchema[] | null { const cachePath = join(this.project.cachePath, PRODUCER_CACHE); if (!existsSync(cachePath)) { return null; } try { const cache: ProducerCache = JSON.parse(readFileSync(cachePath, 'utf-8')); // Check version if (cache.metadata.version !== CACHE_VERSION) { return null; } // Check all files are still valid for (const file of files) { const relPath = relative(this.project.rootDir, file); const cached = cache.metadata.checksums[relPath]; if (!cached || !this.isFileValid(file, cached)) { return null; // Cache invalidated } } return cache.schemas; } catch { return null; } } /** * Save producer schemas to cache */ saveProducerSchemas(schemas: ProducerSchema[], files: string[]): void { const checksums: Record<string, FileChecksum> = {}; for (const file of files) { const relPath = relative(this.project.rootDir, file); checksums[relPath] = this.computeChecksum(file); } const cache: ProducerCache = { metadata: { version: CACHE_VERSION, timestamp: new Date().toISOString(), checksums, }, schemas, }; writeFileSync( join(this.project.cachePath, PRODUCER_CACHE), JSON.stringify(cache, null, 2) ); } // -------------------------------------------------------------------------- // Consumer Cache // -------------------------------------------------------------------------- /** * Get cached consumer usage if still valid */ getConsumerUsage(files: string[]): ConsumerSchema[] | null { const cachePath = join(this.project.cachePath, CONSUMER_CACHE); if (!existsSync(cachePath)) { return null; } try { const cache: ConsumerCache = JSON.parse(readFileSync(cachePath, 'utf-8')); if (cache.metadata.version !== CACHE_VERSION) { return null; } for (const file of files) { const relPath = relative(this.project.rootDir, file); const cached = cache.metadata.checksums[relPath]; if (!cached || !this.isFileValid(file, cached)) { return null; } } return cache.usage; } catch { return null; } } /** * Save consumer usage to cache */ saveConsumerUsage(usage: ConsumerSchema[], files: string[]): void { const checksums: Record<string, FileChecksum> = {}; for (const file of files) { const relPath = relative(this.project.rootDir, file); checksums[relPath] = this.computeChecksum(file); } const cache: ConsumerCache = { metadata: { version: CACHE_VERSION, timestamp: new Date().toISOString(), checksums, }, usage, }; writeFileSync( join(this.project.cachePath, CONSUMER_CACHE), JSON.stringify(cache, null, 2) ); } // -------------------------------------------------------------------------- // Incremental Updates // -------------------------------------------------------------------------- /** * Check which files have changed since last cache */ getChangedFiles(files: string[], side: 'producer' | 'consumer'): string[] { const cachePath = join( this.project.cachePath, side === 'producer' ? PRODUCER_CACHE : CONSUMER_CACHE ); if (!existsSync(cachePath)) { return files; // All files are "changed" (no cache) } try { const cache = JSON.parse(readFileSync(cachePath, 'utf-8')); const changed: string[] = []; for (const file of files) { const relPath = relative(this.project.rootDir, file); const cached = cache.metadata?.checksums?.[relPath]; if (!cached || !this.isFileValid(file, cached)) { changed.push(file); } } return changed; } catch { return files; } } /** * Invalidate cache for specific files */ invalidateFiles(files: string[]): void { // For now, just delete the entire cache // Future: Could do incremental invalidation const producerPath = join(this.project.cachePath, PRODUCER_CACHE); const consumerPath = join(this.project.cachePath, CONSUMER_CACHE); // We could be smarter here and just remove the affected checksums // but full invalidation is safer for now if (existsSync(producerPath)) { const cache: ProducerCache = JSON.parse(readFileSync(producerPath, 'utf-8')); let invalidated = false; for (const file of files) { const relPath = relative(this.project.rootDir, file); if (cache.metadata.checksums[relPath]) { delete cache.metadata.checksums[relPath]; invalidated = true; } } if (invalidated) { // Mark cache as partially invalid cache.metadata.timestamp = new Date().toISOString(); writeFileSync(producerPath, JSON.stringify(cache, null, 2)); } } // Same for consumer if (existsSync(consumerPath)) { const cache: ConsumerCache = JSON.parse(readFileSync(consumerPath, 'utf-8')); let invalidated = false; for (const file of files) { const relPath = relative(this.project.rootDir, file); if (cache.metadata.checksums[relPath]) { delete cache.metadata.checksums[relPath]; invalidated = true; } } if (invalidated) { cache.metadata.timestamp = new Date().toISOString(); writeFileSync(consumerPath, JSON.stringify(cache, null, 2)); } } } /** * Clear all cache */ clear(): void { const producerPath = join(this.project.cachePath, PRODUCER_CACHE); const consumerPath = join(this.project.cachePath, CONSUMER_CACHE); if (existsSync(producerPath)) { writeFileSync(producerPath, JSON.stringify({ schemas: [], metadata: { checksums: {} } })); } if (existsSync(consumerPath)) { writeFileSync(consumerPath, JSON.stringify({ usage: [], metadata: { checksums: {} } })); } } // -------------------------------------------------------------------------- // Checksum Utilities // -------------------------------------------------------------------------- private computeChecksum(filePath: string): FileChecksum { const stat = statSync(filePath); const content = readFileSync(filePath); const hash = createHash('sha256').update(content).digest('hex'); return { path: relative(this.project.rootDir, filePath), mtime: stat.mtimeMs, size: stat.size, hash, }; } private isFileValid(filePath: string, cached: FileChecksum): boolean { if (!existsSync(filePath)) { return false; } const stat = statSync(filePath); // Quick check: mtime and size if (stat.mtimeMs !== cached.mtime || stat.size !== cached.size) { return false; } // If mtime/size match, trust the cache (avoid expensive hash) return true; } }