diff --git a/src/store.test.ts b/src/store.test.ts index 237e7b6..f84f730 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -517,6 +517,234 @@ describe('InMemoryEventStore', () => { expect(worker3Collisions).toHaveLength(0); }); }); + + describe('Cross-Reference Integration', () => { + it('should track cross-references when events are added', () => { + const ts = Date.now(); + + // Add events with related entities + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + path: '/src/file.ts', + tool: 'Edit', + ts + })); + + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + path: '/src/file.ts', + tool: 'Write', + ts: ts + 1000 + })); + + // Query cross-references + const stats = store.getCrossReferenceStats(); + expect(stats.totalLinks).toBeGreaterThan(0); + expect(stats.totalEntities).toBeGreaterThan(0); + }); + + it('should create links between events and workers', () => { + const ts = Date.now(); + + store.add(createEvent({ + worker: 'w-test-123', + msg: 'Starting task', + ts + })); + + // Get links for the worker + const links = store.getCrossReferenceLinksForEntity('worker', 'w-test-123'); + expect(links.length).toBeGreaterThan(0); + + // Should have links to events + const eventLinks = links.filter(l => l.targetType === 'event' || l.sourceType === 'event'); + expect(eventLinks.length).toBeGreaterThan(0); + }); + + it('should create links between events and files', () => { + const ts = Date.now(); + const filePath = '/src/test.ts'; + + store.add(createEvent({ + worker: 'w1', + path: filePath, + tool: 'Edit', + ts + })); + + // Get links for the file + const links = store.getCrossReferenceLinksForEntity('file', filePath); + expect(links.length).toBeGreaterThan(0); + }); + + it('should create links between events and beads', () => { + const ts = Date.now(); + const beadId = 'bd-test-123'; + + store.add(createEvent({ + worker: 'w1', + bead: beadId, + msg: 'Working on bead', + ts + })); + + // Get links for the bead + const links = store.getCrossReferenceLinksForEntity('bead', beadId); + expect(links.length).toBeGreaterThan(0); + }); + + it('should find linked entities', () => { + const ts = Date.now(); + const workerId = 'w-linked'; + const filePath = '/src/linked.ts'; + + store.add(createEvent({ + worker: workerId, + path: filePath, + tool: 'Edit', + ts + })); + + // Get linked entities for the worker + const linkedEntities = store.getLinkedEntities('worker', workerId); + expect(linkedEntities.length).toBeGreaterThan(0); + + // Should include event and/or file entity + // Note: file entity linking happens during batch processing + const hasEventOrFileEntity = linkedEntities.some( + e => (e.type === 'event') || (e.type === 'file' && e.id === filePath) + ); + expect(hasEventOrFileEntity).toBe(true); + }); + + it('should get cross-reference entity details', () => { + const ts = Date.now(); + const workerId = 'w-entity-test'; + + store.add(createEvent({ + worker: workerId, + msg: 'Test event', + ts + })); + + // Get entity + const entity = store.getCrossReferenceEntity('worker', workerId); + expect(entity).toBeDefined(); + expect(entity?.type).toBe('worker'); + expect(entity?.id).toBe(workerId); + expect(entity?.linkCount).toBeGreaterThan(0); + }); + + it('should query cross-references with filters', () => { + const ts = Date.now(); + + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + path: '/src/file.ts', + tool: 'Edit', + ts + })); + + // Query links by relationship type + const sameBeadLinks = store.queryCrossReferences({ relationship: 'same_bead' }); + expect(Array.isArray(sameBeadLinks)).toBe(true); + + // Query links by source type + const eventLinks = store.queryCrossReferences({ sourceType: 'event' }); + expect(Array.isArray(eventLinks)).toBe(true); + expect(eventLinks.every(l => l.sourceType === 'event')).toBe(true); + }); + + it('should find navigation paths between entities', () => { + const ts = Date.now(); + const workerId = 'w-path'; + const beadId = 'bd-path'; + + store.add(createEvent({ + worker: workerId, + bead: beadId, + msg: 'Working', + ts + })); + + // Find path from worker to bead + const path = store.findCrossReferencePath('worker', workerId, 'bead', beadId); + + // Path may or may not exist depending on link creation timing + if (path) { + expect(path.start.id).toBe(workerId); + expect(path.end.id).toBe(beadId); + expect(path.length).toBeGreaterThan(0); + } + }); + + it('should clear cross-references when store is cleared', () => { + const ts = Date.now(); + + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + ts + })); + + let stats = store.getCrossReferenceStats(); + expect(stats.totalLinks).toBeGreaterThan(0); + + store.clear(); + + stats = store.getCrossReferenceStats(); + expect(stats.totalLinks).toBe(0); + expect(stats.totalEntities).toBe(0); + }); + + it('should get all cross-reference entities', () => { + const ts = Date.now(); + + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + path: '/src/test.ts', + tool: 'Edit', + ts + })); + + const allEntities = store.getAllCrossReferenceEntities(); + expect(Array.isArray(allEntities)).toBe(true); + expect(allEntities.length).toBeGreaterThan(0); + + // Should have different entity types + const types = new Set(allEntities.map(e => e.type)); + expect(types.size).toBeGreaterThan(1); + }); + + it('should get all cross-reference links', () => { + const ts = Date.now(); + + store.add(createEvent({ + worker: 'w1', + bead: 'bd-1', + path: '/src/test.ts', + tool: 'Edit', + ts + })); + + const allLinks = store.getAllCrossReferenceLinks(); + expect(Array.isArray(allLinks)).toBe(true); + expect(allLinks.length).toBeGreaterThan(0); + + // All links should have required fields + allLinks.forEach(link => { + expect(link.id).toBeDefined(); + expect(link.sourceType).toBeDefined(); + expect(link.targetType).toBeDefined(); + expect(link.relationship).toBeDefined(); + expect(typeof link.strength).toBe('number'); + }); + }); + }); }); describe('getStore and resetStore', () => { diff --git a/src/store.ts b/src/store.ts index a4feecf..666fe26 100644 --- a/src/store.ts +++ b/src/store.ts @@ -26,9 +26,16 @@ import { RecoverySuggestion, RecoveryOptions, RecoveryStats, + CrossReferenceLink, + CrossReferenceEntity, + CrossReferenceEntityType, + CrossReferenceQueryOptions, + CrossReferenceStats, + CrossReferencePath, } from './types.js'; import { ErrorGroupManager, getErrorGroupManager } from './errorGrouping.js'; import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlaybook.js'; +import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js'; /** Time window (in ms) to consider events as concurrent */ const COLLISION_WINDOW_MS = 5000; @@ -71,13 +78,17 @@ export class InMemoryEventStore implements EventStore { private fileModifications: Map = new Map(); private errorGroupManager: ErrorGroupManager; private recoveryManager: RecoveryManager; + private crossReferenceManager: CrossReferenceManager; private maxEvents: number; private alertCounter = 0; + private batchBuffer: LogEvent[] = []; + private batchTimeout: NodeJS.Timeout | null = null; constructor(maxEvents: number = 10000) { this.maxEvents = maxEvents; this.errorGroupManager = new ErrorGroupManager(); this.recoveryManager = getRecoveryManager(); + this.crossReferenceManager = getCrossReferenceManager(); } /** @@ -96,12 +107,36 @@ export class InMemoryEventStore implements EventStore { this.errorGroupManager.addError(event); } + // Process event for cross-references (immediate) + this.crossReferenceManager.processEvent(event); + + // Add to batch buffer for relationship detection + this.batchBuffer.push(event); + this.scheduleBatchProcessing(); + // Trim if over limit if (this.events.length > this.maxEvents) { this.events.shift(); } } + /** + * Schedule batch processing for cross-reference relationship detection + */ + private scheduleBatchProcessing(): void { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout); + } + + this.batchTimeout = setTimeout(() => { + if (this.batchBuffer.length > 0) { + this.crossReferenceManager.processBatch([...this.batchBuffer]); + this.batchBuffer = []; + } + this.batchTimeout = null; + }, 1000); // Process batch every 1 second + } + /** * Query events with optional filter */ @@ -162,6 +197,12 @@ export class InMemoryEventStore implements EventStore { this.taskCollisions.clear(); this.fileModifications.clear(); this.errorGroupManager.clear(); + this.crossReferenceManager.clear(); + this.batchBuffer = []; + if (this.batchTimeout) { + clearTimeout(this.batchTimeout); + this.batchTimeout = null; + } } /** @@ -1049,6 +1090,94 @@ export class InMemoryEventStore implements EventStore { clearRecoverySuggestions(): void { this.recoveryManager.clear(); } + + // ============================================ + // Cross-Reference Methods + // ============================================ + + /** + * Query cross-references with optional filter + */ + queryCrossReferences(filter?: CrossReferenceQueryOptions): CrossReferenceLink[] { + return this.crossReferenceManager.query(filter); + } + + /** + * Get all links for a specific entity + */ + getCrossReferenceLinksForEntity( + type: CrossReferenceEntityType, + id: string + ): CrossReferenceLink[] { + return this.crossReferenceManager.getLinksForEntity(type, id); + } + + /** + * Get linked entities for a specific entity + */ + getLinkedEntities( + type: CrossReferenceEntityType, + id: string + ): CrossReferenceEntity[] { + return this.crossReferenceManager.getLinkedEntities(type, id); + } + + /** + * Find a navigation path between two entities + */ + findCrossReferencePath( + sourceType: CrossReferenceEntityType, + sourceId: string, + targetType: CrossReferenceEntityType, + targetId: string, + maxDepth?: number + ): CrossReferencePath | null { + return this.crossReferenceManager.findPath( + sourceType, + sourceId, + targetType, + targetId, + maxDepth + ); + } + + /** + * Get cross-reference statistics + */ + getCrossReferenceStats(): CrossReferenceStats { + return this.crossReferenceManager.getStats(); + } + + /** + * Get entity by type and ID + */ + getCrossReferenceEntity( + type: CrossReferenceEntityType, + id: string + ): CrossReferenceEntity | undefined { + return this.crossReferenceManager.getEntity(type, id); + } + + /** + * Get all cross-reference entities + */ + getAllCrossReferenceEntities(): CrossReferenceEntity[] { + return this.crossReferenceManager.getAllEntities(); + } + + /** + * Get all cross-reference links + */ + getAllCrossReferenceLinks(): CrossReferenceLink[] { + return this.crossReferenceManager.getAllLinks(); + } + + /** + * Clear all cross-references + */ + clearCrossReferences(): void { + this.crossReferenceManager.clear(); + } } /** diff --git a/src/web/server.ts b/src/web/server.ts index 06f618f..4ce7344 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -12,7 +12,6 @@ import { fileURLToPath } from 'url'; import { WebSocketServer, WebSocket } from 'ws'; import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship, DagOptions, BeadStatus } from '../types.js'; import { InMemoryEventStore } from '../store.js'; -import { CrossReferenceManager, getCrossReferenceManager } from '../crossReferenceManager.js'; import { refreshDependencyGraph, getDagStats } from '../tui/dagUtils.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -196,12 +195,9 @@ export function createWebServer(options: WebServerOptions): WebServer { // Cross-Reference API Endpoints // ============================================ - // Get cross-reference manager instance - const xrefManager = getCrossReferenceManager(); - // Get cross-reference statistics app.get('/api/xref/stats', (_req: Request, res: Response) => { - const stats = xrefManager.getStats(); + const stats = store.getCrossReferenceStats(); res.json(stats); }); @@ -213,7 +209,7 @@ export function createWebServer(options: WebServerOptions): WebServer { const minStrength = req.query.minStrength ? parseFloat(req.query.minStrength as string) : undefined; const limit = req.query.limit ? parseInt(req.query.limit as string) : 100; - const links = xrefManager.query({ + const links = store.queryCrossReferences({ sourceType, targetType, relationship, @@ -226,7 +222,7 @@ export function createWebServer(options: WebServerOptions): WebServer { // Get all tracked entities app.get('/api/xref/entities', (_req: Request, res: Response) => { - const entities = xrefManager.getAllEntities(); + const entities = store.getAllCrossReferenceEntities(); res.json(entities); }); @@ -234,7 +230,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.get('/api/xref/entities/:type/:id', (req: Request, res: Response) => { const type = req.params.type as CrossReferenceEntityType; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const entity = xrefManager.getEntity(type, id); + const entity = store.getCrossReferenceEntity(type, id); if (!entity) { res.status(404).json({ error: 'Entity not found' }); @@ -248,7 +244,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.get('/api/xref/entities/:type/:id/links', (req: Request, res: Response) => { const type = req.params.type as CrossReferenceEntityType; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const links = xrefManager.getLinksForEntity(type, id); + const links = store.getCrossReferenceLinksForEntity(type, id); res.json(links); }); @@ -256,7 +252,7 @@ export function createWebServer(options: WebServerOptions): WebServer { app.get('/api/xref/entities/:type/:id/related', (req: Request, res: Response) => { const type = req.params.type as CrossReferenceEntityType; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; - const related = xrefManager.getLinkedEntities(type, id); + const related = store.getLinkedEntities(type, id); res.json(related); }); @@ -273,7 +269,7 @@ export function createWebServer(options: WebServerOptions): WebServer { return; } - const path = xrefManager.findPath(sourceType, sourceId, targetType, targetId, maxDepth); + const path = store.findCrossReferencePath(sourceType, sourceId, targetType, targetId, maxDepth); if (!path) { res.status(404).json({ error: 'No path found between entities' });