FABRIC/src/crossReferenceManager.ts
jedarden 9938630bdd feat(web): add ErrorGroupPanel with grouped error cards and similar past errors
Port TUI ErrorGroupPanel to React — groups errors by signature with
occurrence count, affected workers, time span, severity badges, and
expandable detail cards. Links to similar past errors from fabric.db
error_history via /api/errors/history/similar endpoint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 06:16:46 -04:00

771 lines
21 KiB
TypeScript

/**
* FABRIC Cross-Reference Manager
*
* Detects and manages relationships between events, tasks, files, and workers.
* Enables hyperlinking across the FABRIC dashboard for navigation.
*/
import {
LogEvent,
CrossReferenceLink,
CrossReferenceEntity,
CrossReferenceEntityType,
CrossReferenceRelationship,
CrossReferenceQueryOptions,
CrossReferenceStats,
CrossReferencePath,
} from './types.js';
/** Time window (ms) to consider events as temporally related */
const TEMPORAL_WINDOW_MS = 30000; // 30 seconds
/** Minimum strength threshold for links */
const MIN_STRENGTH = 0.1;
/** Maximum links to store */
const MAX_LINKS = 5000;
/** Maximum entities to track (prevents unbounded growth from per-event entries) */
const MAX_ENTITIES = 2000;
/** Maximum entries in the event index */
const MAX_EVENT_INDEX = 5000;
/**
* Generate a unique ID for a cross-reference link
*/
function generateLinkId(
sourceType: CrossReferenceEntityType,
sourceId: string,
targetType: CrossReferenceEntityType,
targetId: string,
relationship: CrossReferenceRelationship
): string {
return `${sourceType}:${sourceId}->${relationship}:${targetType}:${targetId}`;
}
/**
* Generate a unique ID for an entity
*/
function generateEntityId(type: CrossReferenceEntityType, id: string): string {
return `${type}:${id}`;
}
/**
* Internal tracking structure for entities
*/
interface InternalEntity {
type: CrossReferenceEntityType;
id: string;
firstSeen: number;
lastSeen: number;
occurrenceCount: number;
label: string;
}
/**
* Cross-Reference Manager
*
* Tracks relationships between events, workers, files, and beads.
*/
export class CrossReferenceManager {
private links: Map<string, CrossReferenceLink> = new Map();
private entities: Map<string, InternalEntity> = new Map();
private workerIndex: Map<string, string[]> = new Map();
private fileIndex: Map<string, string[]> = new Map();
private beadIndex: Map<string, string[]> = new Map();
/**
* Process a log event and extract cross-references.
* Per-event entities and links are skipped — only durable entities
* (worker, file, bead) and cross-entity relationships are tracked.
*/
processEvent(event: LogEvent): void {
if (event.worker) {
this.registerEntity('worker', event.worker, event.ts, `Worker ${event.worker.slice(0, 8)}`);
}
if (event.path) {
const fileName = event.path.split('/').pop() || event.path;
this.registerEntity('file', event.path, event.ts, fileName);
}
if (event.bead) {
this.registerEntity('bead', event.bead, event.ts, `Task ${event.bead}`);
}
}
/**
* Process multiple events and find relationships
*/
processBatch(events: LogEvent[]): void {
for (const event of events) {
this.processEvent(event);
}
this.findTemporalRelationships(events);
this.findBeadRelationships(events);
this.findFileRelationships(events);
this.findToolSequences(events);
}
/**
* Get human-readable label for an event
*/
private getEventLabel(event: LogEvent): string {
const time = new Date(event.ts).toLocaleTimeString();
const msg = event.msg?.slice(0, 30) || 'Event';
return `${time} ${msg}`;
}
/**
* Register an entity in the tracking system.
* Event-type entities are skipped to avoid unbounded per-event growth.
*/
private registerEntity(
type: CrossReferenceEntityType,
id: string,
timestamp: number,
label: string
): void {
// Skip event-type entities — they're transient and create one entry per event
if (type === 'event') return;
const entityId = generateEntityId(type, id);
const existing = this.entities.get(entityId);
if (existing) {
existing.lastSeen = timestamp;
existing.occurrenceCount++;
} else {
if (this.entities.size >= MAX_ENTITIES) {
this.trimEntities();
}
this.entities.set(entityId, {
type,
id,
firstSeen: timestamp,
lastSeen: timestamp,
occurrenceCount: 1,
label,
});
}
}
/**
* Convert internal entity to public CrossReferenceEntity format
*/
private toCrossReferenceEntity(internal: InternalEntity): CrossReferenceEntity {
const links = this.getLinksForEntity(internal.type, internal.id);
const outgoingLinks = links.filter(l => l.sourceType === internal.type && l.sourceId === internal.id);
const incomingLinks = links.filter(l => l.targetType === internal.type && l.targetId === internal.id);
const relatedEntities = new Map<CrossReferenceEntityType, CrossReferenceLink[]>();
for (const link of links) {
const targetType = link.targetType;
if (!relatedEntities.has(targetType)) {
relatedEntities.set(targetType, []);
}
relatedEntities.get(targetType)!.push(link);
}
return {
type: internal.type,
id: internal.id,
label: internal.label,
outgoingLinks,
incomingLinks,
relatedEntities,
linkCount: links.length,
lastLinkedAt: links.length > 0 ? Math.max(...links.map(l => l.detectedAt)) : internal.lastSeen,
firstSeen: internal.firstSeen,
occurrenceCount: internal.occurrenceCount,
};
}
/**
* Create a cross-reference link
*/
private createLink(
sourceType: CrossReferenceEntityType,
sourceId: string,
targetType: CrossReferenceEntityType,
targetId: string,
relationship: CrossReferenceRelationship,
strength: number,
timestamp: number,
context?: string
): CrossReferenceLink | null {
if (sourceType === targetType && sourceId === targetId) {
return null;
}
const linkId = generateLinkId(sourceType, sourceId, targetType, targetId, relationship);
const existing = this.links.get(linkId);
if (existing) {
existing.strength = Math.min(1.0, existing.strength + 0.1);
existing.detectedAt = timestamp;
return existing;
}
const link: CrossReferenceLink = {
id: linkId,
sourceType,
sourceId,
targetType,
targetId,
relationship,
strength: Math.min(1.0, Math.max(MIN_STRENGTH, strength)),
detectedAt: timestamp,
context,
};
this.links.set(linkId, link);
this.addToIndex(sourceType, sourceId, linkId);
this.addToIndex(targetType, targetId, linkId);
if (this.links.size > MAX_LINKS) {
this.trimOldLinks();
}
return link;
}
/**
* Add link ID to appropriate index
*/
private addToIndex(type: CrossReferenceEntityType, key: string, linkId: string): void {
const indexMap = this.getIndexMap(type);
if (!indexMap) return;
if (!indexMap.has(key)) {
indexMap.set(key, []);
}
const linkIds = indexMap.get(key)!;
if (!linkIds.includes(linkId)) {
linkIds.push(linkId);
}
}
/**
* Get the index map for an entity type
*/
private getIndexMap(type: CrossReferenceEntityType): Map<string, string[]> | null {
switch (type) {
case 'worker': return this.workerIndex;
case 'file': return this.fileIndex;
case 'bead': return this.beadIndex;
default: return null;
}
}
/**
* Find temporal relationships between events
*/
private findTemporalRelationships(events: LogEvent[]): void {
const sorted = [...events].sort((a, b) => a.ts - b.ts);
for (let i = 0; i < sorted.length; i++) {
const event = sorted[i];
for (let j = i + 1; j < sorted.length; j++) {
const other = sorted[j];
const timeDiff = other.ts - event.ts;
if (timeDiff > TEMPORAL_WINDOW_MS) break;
if (event.worker === other.worker && event.ts === other.ts) continue;
const strength = 1.0 - (timeDiff / TEMPORAL_WINDOW_MS);
this.createLink(
'event',
this.getEventId(event),
'event',
this.getEventId(other),
'temporal_proximity',
strength,
event.ts,
`${Math.round(timeDiff / 1000)}s apart`
);
}
}
}
/**
* Find relationships between events on the same bead
*/
private findBeadRelationships(events: LogEvent[]): void {
const beadEvents = new Map<string, LogEvent[]>();
for (const event of events) {
if (event.bead) {
if (!beadEvents.has(event.bead)) {
beadEvents.set(event.bead, []);
}
beadEvents.get(event.bead)!.push(event);
}
}
for (const [beadId, beadEventList] of beadEvents) {
const workers = [...new Set(beadEventList.map(e => e.worker))];
for (let i = 0; i < workers.length; i++) {
for (let j = i + 1; j < workers.length; j++) {
if (workers[i] !== workers[j]) {
const firstEvent = beadEventList.find(e => e.worker === workers[i])!;
this.createLink(
'worker',
workers[i],
'worker',
workers[j],
'same_bead',
0.8,
firstEvent.ts,
`Both worked on ${beadId}`
);
}
}
}
}
}
/**
* Find relationships between events on the same file
*/
private findFileRelationships(events: LogEvent[]): void {
const fileGroups = new Map<string, LogEvent[]>();
for (const event of events) {
if (event.path) {
if (!fileGroups.has(event.path)) {
fileGroups.set(event.path, []);
}
fileGroups.get(event.path)!.push(event);
}
}
for (const [filePath, fileEvents] of fileGroups) {
const workers = [...new Set(fileEvents.map(e => e.worker))];
const fileName = filePath.split('/').pop() || filePath;
for (let i = 0; i < workers.length; i++) {
for (let j = i + 1; j < workers.length; j++) {
if (workers[i] !== workers[j]) {
const sorted = [...fileEvents].sort((a, b) => a.ts - b.ts);
const firstEvent = sorted[0];
this.createLink(
'worker',
workers[i],
'worker',
workers[j],
'same_file',
0.7,
firstEvent.ts,
`Both modified ${fileName}`
);
}
}
}
const sorted = [...fileEvents].sort((a, b) => a.ts - b.ts);
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const timeDiff = sorted[j].ts - sorted[i].ts;
if (timeDiff < TEMPORAL_WINDOW_MS && sorted[i].worker !== sorted[j].worker) {
this.createLink(
'worker',
sorted[i].worker,
'worker',
sorted[j].worker,
'collision',
0.9,
sorted[i].ts,
`Collision on ${fileName}`
);
}
}
}
}
}
/**
* Find tool sequence relationships
*/
private findToolSequences(events: LogEvent[]): void {
const workerEvents = new Map<string, LogEvent[]>();
for (const event of events) {
if (!workerEvents.has(event.worker)) {
workerEvents.set(event.worker, []);
}
workerEvents.get(event.worker)!.push(event);
}
for (const [, workerEventList] of workerEvents) {
const sorted = [...workerEventList].sort((a, b) => a.ts - b.ts);
for (let i = 0; i < sorted.length - 1; i++) {
const current = sorted[i];
const next = sorted[i + 1];
if (current.tool && next.tool) {
const timeDiff = next.ts - current.ts;
if (timeDiff < 60000) {
this.createLink(
'event',
this.getEventId(current),
'event',
this.getEventId(next),
'tool_sequence',
0.6,
current.ts,
`${current.tool} -> ${next.tool}`
);
}
}
}
}
}
/**
* Get unique event ID for an event
*/
private getEventId(event: LogEvent): string {
return `${event.ts}-${event.worker}`;
}
/**
* Query cross-references with optional filter
*/
query(filter?: CrossReferenceQueryOptions): CrossReferenceLink[] {
let results = Array.from(this.links.values());
if (!filter) return results;
if (filter.sourceType) {
results = results.filter(l => l.sourceType === filter.sourceType);
}
if (filter.targetType) {
results = results.filter(l => l.targetType === filter.targetType);
}
if (filter.relationship) {
results = results.filter(l => l.relationship === filter.relationship);
}
if (filter.minStrength !== undefined) {
results = results.filter(l => l.strength >= filter.minStrength!);
}
if (filter.since !== undefined) {
results = results.filter(l => l.detectedAt >= filter.since!);
}
if (filter.until !== undefined) {
results = results.filter(l => l.detectedAt <= filter.until!);
}
results.sort((a, b) => {
if (b.strength !== a.strength) return b.strength - a.strength;
return b.detectedAt - a.detectedAt;
});
if (filter.limit !== undefined) {
results = results.slice(0, filter.limit);
}
return results;
}
/**
* Get all links for a specific entity
*/
getLinksForEntity(type: CrossReferenceEntityType, id: string): CrossReferenceLink[] {
const indexMap = this.getIndexMap(type);
if (!indexMap) return [];
const linkIds = indexMap.get(id) || [];
return linkIds
.map(linkId => this.links.get(linkId))
.filter((link): link is CrossReferenceLink => link !== undefined);
}
/**
* Get linked entities for a specific entity
*/
getLinkedEntities(type: CrossReferenceEntityType, id: string): CrossReferenceEntity[] {
const links = this.getLinksForEntity(type, id);
const internalEntities: InternalEntity[] = [];
for (const link of links) {
const targetEntityId = generateEntityId(link.targetType, link.targetId);
const targetEntity = this.entities.get(targetEntityId);
if (targetEntity) {
internalEntities.push(targetEntity);
}
if (link.sourceType !== type || link.sourceId !== id) {
const sourceEntityId = generateEntityId(link.sourceType, link.sourceId);
const sourceEntity = this.entities.get(sourceEntityId);
if (sourceEntity) {
internalEntities.push(sourceEntity);
}
}
}
const seen = new Set<string>();
return internalEntities.filter(e => {
const key = generateEntityId(e.type, e.id);
if (seen.has(key)) return false;
seen.add(key);
return true;
}).map(e => this.toCrossReferenceEntity(e));
}
/**
* Find a navigation path between two entities
*/
findPath(
sourceType: CrossReferenceEntityType,
sourceId: string,
targetType: CrossReferenceEntityType,
targetId: string,
maxDepth: number = 5
): CrossReferencePath | null {
const sourceEntityId = generateEntityId(sourceType, sourceId);
const targetEntityId = generateEntityId(targetType, targetId);
const sourceInternal = this.entities.get(sourceEntityId);
const targetInternal = this.entities.get(targetEntityId);
if (!sourceInternal || !targetInternal) return null;
const queue: { entityId: string; path: CrossReferenceLink[] }[] = [
{ entityId: sourceEntityId, path: [] },
];
const visited = new Set<string>();
visited.add(sourceEntityId);
while (queue.length > 0) {
const current = queue.shift()!;
if (current.path.length > maxDepth) continue;
const [currentType, currentId] = current.entityId.split(':') as [CrossReferenceEntityType, string];
const links = this.getLinksForEntity(currentType, currentId);
for (const link of links) {
const nextEntityId = generateEntityId(link.targetType, link.targetId);
if (nextEntityId === targetEntityId) {
const sourceEntity = this.toCrossReferenceEntity(sourceInternal);
const targetEntity = this.toCrossReferenceEntity(targetInternal);
return {
start: sourceEntity,
end: targetEntity,
steps: [...current.path, link],
length: current.path.length + 1,
description: this.describePath([...current.path, link]),
};
}
if (!visited.has(nextEntityId)) {
visited.add(nextEntityId);
queue.push({
entityId: nextEntityId,
path: [...current.path, link],
});
}
}
}
return null;
}
/**
* Generate a human-readable description of a path
*/
private describePath(path: CrossReferenceLink[]): string {
if (path.length === 0) return 'Direct link';
const parts: string[] = [];
for (const link of path) {
switch (link.relationship) {
case 'same_bead':
parts.push(`same task (${link.targetId})`);
break;
case 'same_file':
parts.push(`file: ${link.targetId.split('/').pop()}`);
break;
case 'same_worker':
parts.push(`worker: ${link.targetId.slice(0, 8)}`);
break;
case 'temporal_proximity':
parts.push('around same time');
break;
case 'collision':
parts.push('collision');
break;
case 'tool_sequence':
parts.push('tool sequence');
break;
default:
parts.push(link.relationship);
}
}
return parts.join(' -> ');
}
/**
* Get statistics about cross-references
*/
getStats(): CrossReferenceStats {
const byRelationship: Record<CrossReferenceRelationship, number> = {
same_bead: 0,
same_file: 0,
same_worker: 0,
temporal_proximity: 0,
same_session: 0,
dependency: 0,
collision: 0,
parent_child: 0,
error_related: 0,
tool_sequence: 0,
};
const byEntityType: Record<CrossReferenceEntityType, number> = {
event: 0,
worker: 0,
file: 0,
bead: 0,
session: 0,
};
for (const link of this.links.values()) {
byRelationship[link.relationship]++;
}
for (const entity of this.entities.values()) {
byEntityType[entity.type]++;
}
const entityLinkCounts = new Map<string, number>();
for (const link of this.links.values()) {
const sourceKey = generateEntityId(link.sourceType, link.sourceId);
const targetKey = generateEntityId(link.targetType, link.targetId);
entityLinkCounts.set(sourceKey, (entityLinkCounts.get(sourceKey) || 0) + 1);
entityLinkCounts.set(targetKey, (entityLinkCounts.get(targetKey) || 0) + 1);
}
const sortedEntities = Array.from(entityLinkCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([entityId]) => this.entities.get(entityId))
.filter((e): e is InternalEntity => e !== undefined)
.map(e => this.toCrossReferenceEntity(e));
const recentLinks = Array.from(this.links.values())
.sort((a, b) => b.detectedAt - a.detectedAt)
.slice(0, 10);
return {
totalLinks: this.links.size,
totalEntities: this.entities.size,
byRelationship,
byEntityType,
mostLinked: sortedEntities,
recentLinks,
};
}
/**
* Trim old links when over limit
*/
private trimOldLinks(): void {
const sorted = Array.from(this.links.entries())
.sort((a, b) => b[1].detectedAt - a[1].detectedAt);
const toKeep = new Map(sorted.slice(0, MAX_LINKS / 2));
this.links = toKeep;
this.rebuildIndices();
}
/**
* Trim oldest-seen entities when over the entity cap.
*/
private trimEntities(): void {
const sorted = Array.from(this.entities.entries())
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
const toRemove = sorted.slice(0, Math.floor(MAX_ENTITIES * 0.2));
for (const [entityId] of toRemove) {
this.entities.delete(entityId);
}
}
/**
* Rebuild all indices from current links
*/
private rebuildIndices(): void {
this.workerIndex.clear();
this.fileIndex.clear();
this.beadIndex.clear();
for (const [linkId, link] of this.links) {
this.addToIndex(link.sourceType, link.sourceId, linkId);
this.addToIndex(link.targetType, link.targetId, linkId);
}
}
/**
* Clear all cross-references
*/
clear(): void {
this.links.clear();
this.entities.clear();
this.workerIndex.clear();
this.fileIndex.clear();
this.beadIndex.clear();
}
/**
* Get entity by type and ID
*/
getEntity(type: CrossReferenceEntityType, id: string): CrossReferenceEntity | undefined {
const internal = this.entities.get(generateEntityId(type, id));
return internal ? this.toCrossReferenceEntity(internal) : undefined;
}
/**
* Get link by ID
*/
getLink(linkId: string): CrossReferenceLink | undefined {
return this.links.get(linkId);
}
/**
* Get all entities
*/
getAllEntities(): CrossReferenceEntity[] {
return Array.from(this.entities.values()).map(e => this.toCrossReferenceEntity(e));
}
/**
* Get all links
*/
getAllLinks(): CrossReferenceLink[] {
return Array.from(this.links.values());
}
}
let globalManager: CrossReferenceManager | undefined;
export function getCrossReferenceManager(): CrossReferenceManager {
if (!globalManager) {
globalManager = new CrossReferenceManager();
}
return globalManager;
}
export function resetCrossReferenceManager(): void {
globalManager = undefined;
}
export default CrossReferenceManager;