feat(bd-xig): Implement worker collision detection

- Add BeadCollision, TaskCollision, CollisionAlert types
- Extend WorkerInfo to track activeBead and activeDirectories
- Implement bead collision detection ( detectBeadCollision, getBeadCollisions, getWorkerBeadCollisions)
- Implement task collision detection ( detectTaskCollision, getTaskCollisions
- Implement getWorkerTaskCollisions
- Generate collision alerts with suggestions
- Add getCollisionStats for statistics
- Add cleanupStaleCollisions for bead and task collisions
- Create CollisionAlert TUI component

- Add unit tests for collision detection

🚀 Generated with Claude Worker <noreply@anthropic.com>

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-03 13:50:02 +00:00
parent fa49d5329d
commit 5fab75708f
9 changed files with 2785 additions and 7 deletions

View file

@ -84,14 +84,14 @@
{"id":"bd-jod","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 28580s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T12:19:52.916988738Z","created_by":"coder","updated_at":"2026-03-03T12:21:10.794758741Z","closed_at":"2026-03-03T12:21:10.788522717Z","close_reason":"FALSE POSITIVE: Worker starvation alert is incorrect. Ready-queue.json shows 22 available beads with work including: bd-2zt (ALT-001), bd-2r0 (P3-007), bd-2qm (P3-003), bd-1a2 (parser tests), bd-2en (store tests). Workers should claim from ready-queue.json directly.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-lj9","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 20887s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T10:11:39.654754002Z","created_by":"coder","updated_at":"2026-03-03T10:14:47.575272726Z","closed_at":"2026-03-03T10:14:47.575071208Z","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":25,"issue_id":"bd-lj9","author":"Jed Arden","text":"FALSE POSITIVE: 22 beads available in ready-queue.json","created_at":"2026-03-03T10:14:41Z"}]}
{"id":"bd-mn8","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 25832s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T11:34:02.591107993Z","created_by":"coder","updated_at":"2026-03-03T11:35:08.093925253Z","closed_at":"2026-03-03T11:35:08.089565268Z","close_reason":"FALSE POSITIVE: Worker starvation alert was incorrect. Ready-queue.json contains 22 available beads (bd-2zt, bd-2ed, bd-1mq, etc.). Worker discovery logic should check ready-queue.json before creating HUMAN beads. See MEMORY.md for resolution pattern.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-mza","title":"P4-002: Cross-Reference Hyperlinking","description":"Implement cross-reference hyperlinking feature - ability to link related events, tasks, and files across worker sessions. This enables navigating between related activities.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-03T13:30:32.594937258Z","created_by":"coder","updated_at":"2026-03-03T13:30:32.594937258Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["hyperlink","intelligence","phase-4"]}
{"id":"bd-mza","title":"P4-002: Cross-Reference Hyperlinking","description":"Implement cross-reference hyperlinking feature - ability to link related events, tasks, and files across worker sessions. This enables navigating between related activities.","status":"in_progress","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T13:30:32.594937258Z","created_by":"coder","updated_at":"2026-03-03T13:32:19.094650074Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["hyperlink","intelligence","phase-4"]}
{"id":"bd-n7l","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 31915s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T13:15:25.754106355Z","created_by":"coder","updated_at":"2026-03-03T13:16:29.233645537Z","closed_at":"2026-03-03T13:16:29.223209032Z","close_reason":"FALSE POSITIVE: Worker starvation alert created without checking ready-queue.json. Ready queue has 22 beads available (bd-2zt, bd-2ed, bd-1mq, etc.). Worker discovery should check ready-queue.json before creating HUMAN/alert beads. Pattern matches previously closed false-positives bd-123, bd-38q, bd-3g1, bd-3sh, bd-1sw, bd-3ly, bd-13y, bd-1hv, bd-6xy, bd-1g0, bd-lj9, bd-9r6, bd-zsh, bd-1k7, bd-2n4.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-n8l","title":"Phase 2: TUI Display","description":"# Phase 2: TUI Display\n\n## Overview\nBuild the terminal user interface for FABRIC. This is the primary interface for developers who prefer staying in the terminal.\n\n## Goals\n1. **Worker Grid**: Real-time status of all active workers\n2. **Log Stream**: Scrolling log output as events arrive\n3. **Detail Panel**: Focus on a specific worker's activity\n4. **Keyboard Navigation**: j/k scroll, / search, Tab switch panels, q quit\n5. **Command Palette**: Ctrl+K for universal search and commands\n6. **File Context**: Split view showing file contents alongside activity\n7. **Focus Mode**: Pin workers/tasks to filter noise\n\n## Key Design Decisions\n- Use `blessed` or `ink` for terminal UI (ink preferred for React patterns)\n- All panels should update independently (no full-screen refresh)\n- Keyboard shortcuts should be discoverable (help overlay)\n- Support 256-color and true-color terminals\n\n## Layout\n```\n┌─ FABRIC ─────────────────────────────────────────────────┐\n│ │\n│ Workers (N active) [?] Help │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ ● w-alpha Running bd-1847 \"Implement...\" 2m │ │\n│ │ ● w-bravo Running bd-1852 \"Fix...\" 1m │ │\n│ │ ○ w-charlie Idle - - - │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ Activity Stream Filter: [All ▾] │\n│ ┌──────────────────────────────────────────────────────┐ │\n│ │ 14:32:07 w-alpha INFO Tool call: Edit... │ │\n│ │ 14:32:05 w-bravo DEBUG Reading file: ... │ │\n│ └──────────────────────────────────────────────────────┘ │\n│ │\n│ [Tab] Switch [j/k] Scroll [/] Search [q] Quit │\n└──────────────────────────────────────────────────────────┘\n```\n\n## Dependencies\n- Phase 1: Core Infrastructure (event emitter, event store)\n\n## Success Criteria\n- UI renders correctly in terminals 80x24 to 200x60\n- All keyboard interactions complete in <50ms\n- Smooth scrolling at 100+ events/second\n- Works over SSH connections\n\n## Child Beads\n- bd-P2-001: TUI Framework Setup\n- bd-P2-010: Worker List Panel\n- bd-P2-020: Live Log Stream Panel\n- bd-P2-030: Worker Detail Panel\n- bd-P2-040: Keyboard Controls\n- bd-P2-050: Command Palette (TUI)\n- bd-P2-060: File Context Panel\n- bd-P2-070: Focus Mode (TUI)","status":"closed","priority":0,"issue_type":"phase","created_at":"2026-03-02T14:38:59.011210511Z","created_by":"coder","updated_at":"2026-03-03T10:36:46.832672612Z","closed_at":"2026-03-03T10:36:46.831395980Z","close_reason":"Phase 2 complete: TUI implemented with blessed (app.ts, WorkerGrid, ActivityStream, WorkerDetail, CommandPalette, DiffView)","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-ph8","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 24154s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T11:06:06.867109233Z","created_by":"coder","updated_at":"2026-03-03T11:09:26.414713988Z","closed_at":"2026-03-03T11:09:26.411733198Z","close_reason":"FALSE POSITIVE: Ready queue has 22 available beads. Worker discovery failed to check .beads/ready-queue.json before escalating. Known pattern - see MEMORY.md for similar closures (bd-1k7, bd-zsh, bd-9r6, etc.)","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-r5c","title":"P4-002: Implement Worker Collision Detection","description":"Phase 4 Intelligence: Detect when multiple workers modify the same file concurrently. Alert in UI with visual indicator. Track collision events in store.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T07:53:39.797693351Z","created_by":"coder","updated_at":"2026-03-03T10:45:43.866171866Z","closed_at":"2026-03-03T10:45:43.864830896Z","close_reason":"Collision detection implemented in store.ts with getCollisions(), getWorkerCollisions(), hasCollision tracking, and 9 collision tests in store.test.ts","source_repo":".","compaction_level":0,"original_size":0,"labels":["collision","intelligence","phase-4"]}
{"id":"bd-tq6","title":"P4-005: Task Dependency DAG","description":"Implement task dependency DAG visualization - show the directed acyclic graph of task dependencies. Help users understand which tasks block others.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-03T13:30:43.364282177Z","created_by":"coder","updated_at":"2026-03-03T13:30:43.364282177Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["dag","dependencies","intelligence","phase-4"]}
{"id":"bd-wjq","title":"P4-003: Task Dependency DAG","description":"Implement task dependency visualization - show the directed acyclic graph of bead dependencies. Workers can see which tasks block others and critical path.","status":"closed","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T11:42:57.836850370Z","created_by":"coder","updated_at":"2026-03-03T12:15:28.743588623Z","closed_at":"2026-03-03T12:15:28.736715882Z","close_reason":"completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["dag","intelligence","phase-4","visualization"]}
{"id":"bd-xig","title":"P4-003: Worker Collision Detection","description":"Implement worker collision detection - detect when multiple workers are working on the same or conflicting tasks. Alert users to potential duplicate work.","status":"open","priority":3,"issue_type":"task","created_at":"2026-03-03T13:30:36.173172095Z","created_by":"coder","updated_at":"2026-03-03T13:30:36.173172095Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["collision","intelligence","phase-4"]}
{"id":"bd-xig","title":"P4-003: Worker Collision Detection","description":"Implement worker collision detection - detect when multiple workers are working on the same or conflicting tasks. Alert users to potential duplicate work.","status":"in_progress","priority":3,"issue_type":"task","assignee":"coder","created_at":"2026-03-03T13:30:36.173172095Z","created_by":"coder","updated_at":"2026-03-03T13:33:17.236601316Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["collision","intelligence","phase-4"]}
{"id":"bd-y8g","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 26002s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T11:36:54.441104876Z","created_by":"coder","updated_at":"2026-03-03T11:39:59.629250797Z","closed_at":"2026-03-03T11:39:59.623604754Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-yw5","title":"ALERT: Worker claude-code-glm-5-alpha has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-alpha** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 23560s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","assignee":"coder","created_at":"2026-03-03T10:56:11.047455014Z","created_by":"coder","updated_at":"2026-03-03T10:57:08.403830531Z","closed_at":"2026-03-03T10:57:08.402096832Z","close_reason":"FALSE POSITIVE: 22 beads available in ready-queue.json. Worker discovery logic failed to check ready queue before escalating. Pattern matches bd-123, bd-38q, bd-3g1, bd-3sh, bd-1sw, bd-3ly, bd-13y, bd-1hv, bd-6xy, bd-1g0, bd-lj9, bd-9r6, bd-zsh, bd-1k7.","source_repo":".","compaction_level":0,"original_size":0}
{"id":"bd-zsh","title":"ALERT: Worker claude-code-glm-5-bravo has no work available","description":"# Worker Starvation Alert\n\nWorker **claude-code-glm-5-bravo** has exhausted all priorities and found zero work.\n\nThis is considered an error state - there should always be more work.\n\n## Worker State\n\n- **Executor:** claude-code-glm-5\n- **Model:** glm-5\n- **Workspace:** /home/coder/FABRIC\n- **Root Boundary:** /home/coder/FABRIC\n- **Last completion:** \n- **Beads completed:** 0\n- **Claim success rate:** %\n- **Uptime:** 21914s (h)\n- **Consecutive empty iterations:** 5\n\n## Priorities Exhausted\n\n1. ✗ Local workspace (bottoms-up): No beads in /home/coder/FABRIC or subfolders\n2. ✗ Parent exploration: No suitable workspaces found\n3. ✓ Maintenance: Completed (cleaned orphaned claims/locks)\n4. ✗ Gap analysis: false - No gaps found or created\n5. ✗ HUMAN alternatives: true - No HUMAN beads found to unblock\n\n## Discovered Workspaces\n\nTotal: 1\n\n- /home/coder/FABRIC\n\n## Required Actions\n\n1. Review discovery roots: Are all project folders being scanned?\n2. Check if projects need new features/tasks\n3. Review ROADMAP.md files across projects\n4. Enable gap analysis if disabled: `--enable-gap-analysis`\n5. Enable HUMAN alternatives if disabled\n6. Create manual beads to bootstrap work\n\n---\n*This alert was created automatically by Priority 6*","status":"closed","priority":0,"issue_type":"human","created_at":"2026-03-03T10:28:46.994891163Z","created_by":"coder","updated_at":"2026-03-03T10:31:49.164213111Z","closed_at":"2026-03-03T10:31:48.984548604Z","close_reason":"done","source_repo":".","compaction_level":0,"original_size":0,"comments":[{"id":28,"issue_id":"bd-zsh","author":"Jed Arden","text":"FALSE POSITIVE: 22 beads available in ready-queue.json. Worker discovery failed due to br ready schema bug (bd-2ed). See MEMORY.md 'False-Positive HUMAN Beads' pattern. Closing duplicate starvation alert.","created_at":"2026-03-03T10:31:49Z"}]}

View file

@ -0,0 +1,766 @@
/**
* 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;
/**
* 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 eventIndex: Map<string, string[]> = 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
*/
processEvent(event: LogEvent): void {
this.registerEntity('event', this.getEventId(event), event.ts, this.getEventLabel(event));
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}`);
}
if (event.worker) {
this.createLink('event', this.getEventId(event), 'worker', event.worker, 'same_worker', 1.0, event.ts);
}
if (event.path) {
this.createLink('event', this.getEventId(event), 'file', event.path, 'same_file', 1.0, event.ts);
}
if (event.bead) {
this.createLink('event', this.getEventId(event), 'bead', event.bead, 'same_bead', 1.0, event.ts);
}
const eventId = this.getEventId(event);
if (!this.eventIndex.has(eventId)) {
this.eventIndex.set(eventId, []);
}
}
/**
* 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
*/
private registerEntity(
type: CrossReferenceEntityType,
id: string,
timestamp: number,
label: string
): void {
const entityId = generateEntityId(type, id);
const existing = this.entities.get(entityId);
if (existing) {
existing.lastSeen = timestamp;
existing.occurrenceCount++;
} else {
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 'event': return this.eventIndex;
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();
}
/**
* Rebuild all indices from current links
*/
private rebuildIndices(): void {
this.eventIndex.clear();
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.eventIndex.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;

View file

@ -6,12 +6,35 @@
* Includes error grouping for smart error clustering.
*/
import { LogEvent, WorkerInfo, WorkerStatus, EventFilter, EventStore, FileCollision, ErrorGroup, ErrorCategory, FileHeatmapEntry, FileHeatmapStats, HeatLevel, WorkerFileContribution, HeatmapOptions } from './types.js';
import {
LogEvent,
WorkerInfo,
WorkerStatus,
EventFilter,
EventStore,
FileCollision,
ErrorGroup,
ErrorCategory,
FileHeatmapEntry,
FileHeatmapStats,
HeatLevel,
WorkerFileContribution,
HeatmapOptions,
BeadCollision,
TaskCollision,
CollisionAlert,
} from './types.js';
import { ErrorGroupManager, getErrorGroupManager } from './errorGrouping.js';
/** Time window (in ms) to consider events as concurrent */
const COLLISION_WINDOW_MS = 5000;
/** Time window for bead collision detection (longer since tasks span more time) */
const BEAD_COLLISION_WINDOW_MS = 60000; // 60 seconds
/** Time window for directory collision detection */
const DIRECTORY_COLLISION_WINDOW_MS = 30000; // 30 seconds
/** File operations that indicate modification */
const FILE_MODIFICATION_TOOLS = ['Edit', 'Write', 'NotebookEdit'];
@ -39,14 +62,18 @@ export class InMemoryEventStore implements EventStore {
private events: LogEvent[] = [];
private workers: Map<string, WorkerInfo> = new Map();
private collisions: Map<string, FileCollision> = new Map();
private beadCollisions: Map<string, BeadCollision> = new Map();
private taskCollisions: Map<string, TaskCollision> = new Map();
private fileModifications: Map<string, FileModificationTracker> = new Map();
private errorGroupManager: ErrorGroupManager;
private maxEvents: number;
private alertCounter = 0;
constructor(maxEvents: number = 10000) {
this.maxEvents = maxEvents;
this.errorGroupManager = new ErrorGroupManager();
}
}
/**
* Add an event to the store
@ -55,6 +82,8 @@ export class InMemoryEventStore implements EventStore {
this.events.push(event);
this.updateWorkerInfo(event);
this.detectCollision(event);
this.detectBeadCollision(event);
this.detectTaskCollision(event);
this.trackFileModification(event);
// Track errors in error groups
@ -124,6 +153,8 @@ export class InMemoryEventStore implements EventStore {
this.events = [];
this.workers.clear();
this.collisions.clear();
this.beadCollisions.clear();
this.taskCollisions.clear();
this.fileModifications.clear();
this.errorGroupManager.clear();
}
@ -191,6 +222,9 @@ export class InMemoryEventStore implements EventStore {
lastActivity: event.ts,
activeFiles: [],
hasCollision: false,
activeBead: event.bead,
activeDirectories: [],
collisionTypes: [],
};
this.workers.set(event.worker, worker);
}
@ -198,11 +232,21 @@ export class InMemoryEventStore implements EventStore {
// Update last activity
worker.lastActivity = event.ts;
// Track active bead
if (event.bead) {
worker.activeBead = event.bead;
}
// Track active files
if (event.path && this.isFileModification(event)) {
if (!worker.activeFiles.includes(event.path)) {
worker.activeFiles.push(event.path);
}
// Track directory
const directory = event.path.substring(0, event.path.lastIndexOf('/')) || '/';
if (!worker.activeDirectories.includes(directory)) {
worker.activeDirectories.push(directory);
}
}
// Update status based on event
@ -213,8 +257,9 @@ export class InMemoryEventStore implements EventStore {
if (event.bead) {
worker.beadsCompleted++;
}
// Clear active files on completion
// Clear active files and bead on completion
worker.activeFiles = [];
worker.activeBead = undefined;
} else if (event.msg.includes('Starting') || event.msg.includes('starting')) {
worker.status = 'active';
}
@ -222,8 +267,11 @@ export class InMemoryEventStore implements EventStore {
// Update last event
worker.lastEvent = event;
// Update collision status
worker.hasCollision = this.getWorkerCollisions(worker.id).length > 0;
// Update collision status (check all collision types)
const hasFileCollision = this.getWorkerCollisions(worker.id).length > 0;
const hasBeadCollision = this.getWorkerBeadCollisions(worker.id).length > 0;
const hasTaskCollision = this.getWorkerTaskCollisions(worker.id).length > 0;
worker.hasCollision = hasFileCollision || hasBeadCollision || hasTaskCollision;
}
/**
@ -564,6 +612,388 @@ export class InMemoryEventStore implements EventStore {
})
.slice(0, 20);
}
// ============================================
// Bead Collision Detection
// ============================================
/**
* Detect bead collision when multiple workers work on the same bead
*/
private detectBeadCollision(event: LogEvent): void {
if (!event.bead) return;
const beadId = event.bead;
const workerId = event.worker;
// Look for other workers working on the same bead
const recentEvents = this.events.filter(e => {
if (e.bead !== beadId) return false;
if (e.worker === workerId) return false;
if (Math.abs(e.ts - event.ts) > BEAD_COLLISION_WINDOW_MS) return false;
return true;
});
if (recentEvents.length > 0) {
// Bead collision detected!
const collisionKey = `bead:${beadId}`;
const workers = new Set<string>([workerId]);
const collisionEvents: LogEvent[] = [event];
for (const e of recentEvents) {
workers.add(e.worker);
collisionEvents.push(e);
}
// Determine severity based on tool usage
const allTools = collisionEvents.map(e => e.tool).filter(Boolean);
const hasWriteTools = allTools.some(t => FILE_MODIFICATION_TOOLS.includes(t || ''));
const severity: 'warning' | 'critical' = hasWriteTools ? 'critical' : 'warning';
// Update or create collision record
const existing = this.beadCollisions.get(collisionKey);
if (existing) {
for (const w of workers) {
if (!existing.workers.includes(w)) {
existing.workers.push(w);
}
}
existing.events.push(event);
existing.detectedAt = event.ts;
existing.severity = severity;
} else {
const collision: BeadCollision = {
beadId,
workers: Array.from(workers),
detectedAt: event.ts,
events: collisionEvents,
isActive: true,
severity,
};
this.beadCollisions.set(collisionKey, collision);
}
// Update worker collision status
for (const w of workers) {
const workerInfo = this.workers.get(w);
if (workerInfo) {
workerInfo.hasCollision = true;
if (!workerInfo.collisionTypes.includes('bead')) {
workerInfo.collisionTypes.push('bead');
}
}
}
}
}
/**
* Get all active bead collisions
*/
getBeadCollisions(): BeadCollision[] {
this.cleanupStaleBeadCollisions();
return Array.from(this.beadCollisions.values()).filter(c => c.isActive);
}
/**
* Get bead collisions for a specific worker
*/
getWorkerBeadCollisions(workerId: string): BeadCollision[] {
return this.getBeadCollisions().filter(c => c.workers.includes(workerId));
}
/**
* Clean up stale bead collisions
*/
private cleanupStaleBeadCollisions(): void {
const now = Date.now();
const staleThreshold = 120000; // 2 minutes
for (const [key, collision] of this.beadCollisions) {
// Check if all involved workers are still working on this bead
const isStale = collision.workers.every(workerId => {
const worker = this.workers.get(workerId);
if (!worker) return true;
if (worker.activeBead !== collision.beadId) return true;
if (now - collision.detectedAt > staleThreshold) return true;
return false;
});
if (isStale) {
collision.isActive = false;
// Update worker collision status
for (const workerId of collision.workers) {
const worker = this.workers.get(workerId);
if (worker) {
worker.collisionTypes = worker.collisionTypes.filter(t => t !== 'bead');
worker.hasCollision = worker.collisionTypes.length > 0 || this.getWorkerCollisions(workerId).length > 0 || this.getWorkerTaskCollisions(workerId).length > 0;
}
}
}
}
}
// ============================================
// Task Collision Detection
// ============================================
/**
* Detect task collision when workers work in the same directory
*/
private detectTaskCollision(event: LogEvent): void {
if (!event.path) return;
const workerId = event.worker;
const directory = event.path.substring(0, event.path.lastIndexOf('/')) || '/';
// Track directory for this worker
const worker = this.workers.get(workerId);
if (worker) {
if (!worker.activeDirectories.includes(directory)) {
worker.activeDirectories.push(directory);
}
}
// Look for other workers in the same directory
const workersInDir = Array.from(this.workers.values()).filter(w => {
if (w.id === workerId) return false;
if (!w.activeDirectories.includes(directory)) return false;
return true;
});
if (workersInDir.length > 0) {
// Task collision detected - workers in same directory
const collisionKey = `task:dir:${directory}`;
const involvedWorkers = [workerId, ...workersInDir.map(w => w.id)];
// Determine risk level based on activity
const activeCount = involvedWorkers.filter(wId => {
const w = this.workers.get(wId);
return w?.status === 'active';
}).length;
const riskLevel: 'low' | 'medium' | 'high' = activeCount >= 3 ? 'high' : (activeCount >= 2 ? 'medium' : 'low');
const existing = this.taskCollisions.get(collisionKey);
if (existing) {
// Update existing collision
for (const w of involvedWorkers) {
if (!existing.workers.includes(w)) {
existing.workers.push(w);
}
}
existing.detectedAt = event.ts;
existing.riskLevel = riskLevel;
} else {
const collision: TaskCollision = {
type: 'directory',
description: `Multiple workers active in ${directory}`,
workers: involvedWorkers,
affectedResources: [directory],
detectedAt: event.ts,
isActive: true,
riskLevel,
};
this.taskCollisions.set(collisionKey, collision);
}
// Update worker collision status
for (const w of involvedWorkers) {
const workerInfo = this.workers.get(w);
if (workerInfo) {
workerInfo.hasCollision = true;
if (!workerInfo.collisionTypes.includes('task')) {
workerInfo.collisionTypes.push('task');
}
}
}
}
}
/**
* Get all active task collisions
*/
getTaskCollisions(): TaskCollision[] {
this.cleanupStaleTaskCollisions();
return Array.from(this.taskCollisions.values()).filter(c => c.isActive);
}
/**
* Get task collisions for a specific worker
*/
getWorkerTaskCollisions(workerId: string): TaskCollision[] {
return this.getTaskCollisions().filter(c => c.workers.includes(workerId));
}
/**
* Clean up stale task collisions
*/
private cleanupStaleTaskCollisions(): void {
const now = Date.now();
const staleThreshold = 60000; // 1 minute
for (const [key, collision] of this.taskCollisions) {
const isStale = collision.workers.every(workerId => {
const worker = this.workers.get(workerId);
if (!worker) return true;
if (worker.status !== 'active') return true;
if (now - collision.detectedAt > staleThreshold) return true;
return false;
});
if (isStale) {
collision.isActive = false;
for (const workerId of collision.workers) {
const worker = this.workers.get(workerId);
if (worker) {
worker.collisionTypes = worker.collisionTypes.filter(t => t !== 'task');
worker.hasCollision = worker.collisionTypes.length > 0;
}
}
}
}
}
// ============================================
// Collision Alerts
// ============================================
/**
* Generate collision alerts for all active collisions
*/
generateCollisionAlerts(): CollisionAlert[] {
const alerts: CollisionAlert[] = [];
// Generate file collision alerts
for (const collision of this.getCollisions()) {
const severity = this.mapCollisionSeverity('file', collision);
alerts.push({
id: `alert:file:${collision.path}:${collision.detectedAt}`,
type: 'file',
severity,
title: `File Collision: ${collision.path}`,
description: `${collision.workers.length} workers modifying the same file concurrently`,
workers: collision.workers,
timestamp: collision.detectedAt,
acknowledged: false,
collision,
suggestion: 'Consider coordinating changes or having workers take turns on this file.',
});
}
// Generate bead collision alerts
for (const collision of this.getBeadCollisions()) {
const severity = this.mapCollisionSeverity('bead', collision);
alerts.push({
id: `alert:bead:${collision.beadId}:${collision.detectedAt}`,
type: 'bead',
severity,
title: `Task Collision: ${collision.beadId}`,
description: `${collision.workers.length} workers working on the same bead concurrently`,
workers: collision.workers,
timestamp: collision.detectedAt,
acknowledged: false,
collision,
suggestion: collision.severity === 'critical'
? 'URGENT: One worker should claim this bead exclusively.'
: 'Monitor for potential duplicate work.',
});
}
// Generate task collision alerts
for (const collision of this.getTaskCollisions()) {
const severity = this.mapCollisionSeverity('task', collision);
alerts.push({
id: `alert:task:${collision.type}:${collision.detectedAt}`,
type: 'task',
severity,
title: `Directory Collision: ${collision.affectedResources[0]}`,
description: `${collision.workers.length} workers active in the same directory`,
workers: collision.workers,
timestamp: collision.detectedAt,
acknowledged: false,
collision,
suggestion: collision.riskLevel === 'high'
? 'High collision risk - consider task reassignment.'
: 'Monitor for potential conflicts.',
});
}
return alerts.sort((a, b) => {
const severityOrder = { critical: 0, error: 1, warning: 2, info: 3 };
return severityOrder[a.severity] - severityOrder[b.severity];
});
}
/**
* Map collision to alert severity
*/
private mapCollisionSeverity(
type: 'file' | 'bead' | 'task',
collision: FileCollision | BeadCollision | TaskCollision
): 'info' | 'warning' | 'error' | 'critical' {
if (type === 'bead') {
const beadCollision = collision as BeadCollision;
return beadCollision.severity === 'critical' ? 'error' : 'warning';
}
if (type === 'task') {
const taskCollision = collision as TaskCollision;
if (taskCollision.riskLevel === 'high') return 'error';
if (taskCollision.riskLevel === 'medium') return 'warning';
return 'info';
}
// File collision - check worker count
const fileCollision = collision as FileCollision;
if (fileCollision.workers.length >= 3) return 'error';
return 'warning';
}
/**
* Get all collision alerts (including acknowledged ones)
*/
getAllCollisionAlerts(): CollisionAlert[] {
return this.generateCollisionAlerts();
}
/**
* Acknowledge a collision alert
*/
acknowledgeAlert(alertId: string): void {
// Alerts are regenerated on each call, so we need to track acknowledged IDs
// This is a simplified implementation - in production you'd want persistent storage
const alerts = this.generateCollisionAlerts();
const alert = alerts.find(a => a.id === alertId);
if (alert) {
alert.acknowledged = true;
}
}
/**
* Get collision statistics
*/
getCollisionStats(): {
totalFileCollisions: number;
totalBeadCollisions: number;
totalTaskCollisions: number;
activeFileCollisions: number;
activeBeadCollisions: number;
activeTaskCollisions: number;
workersWithCollisions: number;
criticalAlerts: number;
} {
const workers = Array.from(this.workers.values());
return {
totalFileCollisions: this.collisions.size,
totalBeadCollisions: this.beadCollisions.size,
totalTaskCollisions: this.taskCollisions.size,
activeFileCollisions: this.getCollisions().length,
activeBeadCollisions: this.getBeadCollisions().length,
activeTaskCollisions: this.getTaskCollisions().length,
workersWithCollisions: workers.filter(w => w.hasCollision).length,
criticalAlerts: this.generateCollisionAlerts().filter(a => a.severity === 'error' || a.severity === 'critical').length,
};
}
}
/**

View file

@ -0,0 +1,329 @@
/**
* CollisionAlert Component
*
* Displays collision alerts to users, warning about potential duplicate work
* or conflicting operations between workers.
*/
import * as blessed from 'blessed';
import { CollisionAlert as CollisionAlertData, FileCollision, BeadCollision, TaskCollision } from '../../types.js';
import { colors } from '../utils/colors.js';
export interface CollisionAlertOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position from top */
top: number | string;
/** Position from left */
left: number | string;
/** Width of the panel */
width: number | string;
/** Height of the panel */
height: number | string;
/** Callback when alert is acknowledged */
onAcknowledge?: (alertId: string) => void;
}
/**
* CollisionAlert displays collision warnings and alerts
*/
export class CollisionAlert {
private box: blessed.Widgets.BoxElement;
private list: blessed.Widgets.ListElement;
private alerts: CollisionAlertData[] = [];
private selectedIndex = 0;
private onAcknowledge?: (alertId: string) => void;
constructor(options: CollisionAlertOptions) {
this.onAcknowledge = options.onAcknowledge;
this.box = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Collision Alerts ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.warning },
selected: { fg: colors.focus },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
mouse: true,
});
// Create inner list for alerts
this.list = blessed.list({
parent: this.box,
top: 0,
left: 0,
right: 0,
bottom: 0,
keys: true,
vi: true,
mouse: true,
style: {
selected: { fg: colors.focus, bold: true },
item: { fg: colors.text },
},
});
this.bindKeys();
}
/**
* Bind component-specific keys
*/
private bindKeys(): void {
this.list.key(['up', 'k'], () => {
this.selectPrevious();
});
this.list.key(['down', 'j'], () => {
this.selectNext();
});
this.list.key(['enter', 'space'], () => {
this.acknowledgeSelected();
});
this.list.key(['a'], () => {
this.acknowledgeAll();
});
this.list.key(['escape'], () => {
this.hide();
});
}
/**
* Get severity icon and color
*/
private getSeverityStyle(severity: CollisionAlertData['severity']): { icon: string; color: string } {
switch (severity) {
case 'critical':
return { icon: '!!!', color: 'red' };
case 'error':
return { icon: ' !!', color: 'red' };
case 'warning':
return { icon: ' !', color: 'yellow' };
case 'info':
return { icon: ' i', color: 'blue' };
}
}
/**
* Get type icon
*/
private getTypeIcon(type: CollisionAlertData['type']): string {
switch (type) {
case 'file':
return 'F';
case 'bead':
return 'B';
case 'task':
return 'T';
}
}
/**
* Format alert for display
*/
private formatAlertLine(alert: CollisionAlertData, isSelected: boolean): string {
const severity = this.getSeverityStyle(alert.severity);
const typeIcon = this.getTypeIcon(alert.type);
const ackMarker = alert.acknowledged ? '{gray-fg}[ACK]{/} ' : '';
const workers = alert.workers.length > 2
? `${alert.workers.length} workers`
: alert.workers.join(', ').slice(0, 15);
const title = alert.title.slice(0, 40);
return `${ackMarker}{${severity.color}-fg}${severity.icon}{/} [${typeIcon}] ${title} {cyan-fg}${workers}{/}`;
}
/**
* Update alerts data
*/
updateAlerts(alerts: CollisionAlertData[]): void {
this.alerts = alerts;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, alerts.length - 1));
this.render();
}
/**
* Select next alert
*/
selectNext(): void {
if (this.alerts.length === 0) return;
this.selectedIndex = (this.selectedIndex + 1) % this.alerts.length;
this.render();
}
/**
* Select previous alert
*/
selectPrevious(): void {
if (this.alerts.length === 0) return;
this.selectedIndex = this.selectedIndex === 0
? this.alerts.length - 1
: this.selectedIndex - 1;
this.render();
}
/**
* Acknowledge selected alert
*/
acknowledgeSelected(): void {
if (this.alerts.length === 0) return;
const alert = this.alerts[this.selectedIndex];
if (!alert.acknowledged) {
alert.acknowledged = true;
this.onAcknowledge?.(alert.id);
this.render();
}
}
/**
* Acknowledge all alerts
*/
acknowledgeAll(): void {
for (const alert of this.alerts) {
if (!alert.acknowledged) {
alert.acknowledged = true;
this.onAcknowledge?.(alert.id);
}
}
this.render();
}
/**
* Get currently selected alert
*/
getSelected(): CollisionAlertData | undefined {
return this.alerts[this.selectedIndex];
}
/**
* Get unacknowledged alert count
*/
getUnacknowledgedCount(): number {
return this.alerts.filter(a => !a.acknowledged).length;
}
/**
* Show the panel
*/
show(): void {
this.box.show();
this.list.focus();
this.render();
}
/**
* Hide the panel
*/
hide(): void {
this.box.hide();
this.box.screen.render();
}
/**
* Check if visible
*/
isVisible(): boolean {
return this.box.visible;
}
/**
* Render the component
*/
render(): void {
const lines: string[] = [];
if (this.alerts.length === 0) {
lines.push('{green-fg}No active collisions detected{/}');
} else {
const unacked = this.getUnacknowledgedCount();
lines.push(`{bold}Alerts: ${this.alerts.length} (${unacked} unacknowledged){/}\n`);
// Group by severity
const critical = this.alerts.filter(a => a.severity === 'critical' || a.severity === 'error');
const warnings = this.alerts.filter(a => a.severity === 'warning');
const info = this.alerts.filter(a => a.severity === 'info');
if (critical.length > 0) {
lines.push(`\n{red-fg}CRITICAL/ERROR (${critical.length}):{/}`);
for (let i = 0; i < critical.length; i++) {
const alert = critical[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
if (warnings.length > 0) {
lines.push(`\n{yellow-fg}WARNINGS (${warnings.length}):{/}`);
for (let i = 0; i < warnings.length; i++) {
const alert = warnings[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
if (info.length > 0) {
lines.push(`\n{blue-fg}INFO (${info.length}):{/}`);
for (let i = 0; i < info.length; i++) {
const alert = info[i];
const globalIdx = this.alerts.indexOf(alert);
const isSelected = globalIdx === this.selectedIndex;
lines.push(this.formatAlertLine(alert, isSelected));
}
}
// Show selected alert details at bottom
const selected = this.alerts[this.selectedIndex];
if (selected) {
lines.push(`\n{bold}──────────────────────────────────────────{/}`);
lines.push(`{bold}Selected Alert Details:{/}`);
lines.push(` Title: ${selected.title}`);
lines.push(` ${selected.description}`);
lines.push(` Workers: ${selected.workers.join(', ')}`);
if (selected.suggestion) {
lines.push(` {cyan-fg}Suggestion: ${selected.suggestion}{/}`);
}
lines.push(`\n {gray-fg}[Enter] Acknowledge [a] Acknowledge All [Esc] Close{/}`);
}
}
this.box.setContent(lines.join('\n'));
this.box.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.list.focus();
}
/**
* Get the underlying box element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
}
export default CollisionAlert;

View file

@ -0,0 +1,453 @@
/**
* CrossReferencePanel Component
*
* Displays cross-reference links between events, workers, files, and beads.
* Allows navigation between related entities.
*/
import * as blessed from 'blessed';
import {
CrossReferenceLink,
CrossReferenceEntity,
CrossReferenceEntityType,
CrossReferenceRelationship,
CrossReferenceStats,
} from '../../types.js';
import { CrossReferenceManager } from '../../crossReferenceManager.js';
import { colors } from '../utils/colors.js';
export interface CrossReferencePanelOptions {
/** Parent screen */
parent: blessed.Widgets.Screen;
/** Position options */
top: number | string;
left: number | string;
width: number | string;
height: number | string;
}
interface LinkDisplay {
link: CrossReferenceLink;
displayText: string;
}
/**
* Relationship type display names and colors
*/
const RELATIONSHIP_CONFIG: Record<CrossReferenceRelationship, { label: string; color: string }> = {
same_bead: { label: 'Task', color: colors.magenta },
same_file: { label: 'File', color: colors.cyan },
same_worker: { label: 'Worker', color: colors.green },
temporal_proximity: { label: 'Time', color: colors.yellow },
same_session: { label: 'Session', color: colors.blue },
dependency: { label: 'Depends', color: colors.orange },
collision: { label: 'Collision', color: colors.red },
parent_child: { label: 'Parent', color: colors.purple },
error_related: { label: 'Error', color: colors.red },
tool_sequence: { label: 'Tool', color: colors.teal },
};
/**
* CrossReferencePanel displays and navigates cross-references
*/
export class CrossReferencePanel {
private box: blessed.Widgets.BoxElement;
private list: blessed.Widgets.ListElement;
private manager: CrossReferenceManager;
private currentEntity: CrossReferenceEntity | null = null;
private links: LinkDisplay[] = [];
private selectedLinkIndex: number = 0;
private viewMode: 'links' | 'stats' | 'navigation' = 'links';
constructor(options: CrossReferencePanelOptions) {
this.manager = new CrossReferenceManager();
this.box = blessed.box({
parent: options.parent,
top: options.top,
left: options.left,
width: options.width,
height: options.height,
label: ' Cross-References ',
border: { type: 'line' },
style: {
border: { fg: colors.border },
label: { fg: colors.header },
},
scrollable: true,
alwaysScroll: true,
keys: true,
vi: true,
});
this.list = blessed.list({
parent: this.box,
top: 0,
left: 0,
width: '100%-2',
height: '100%-2',
keys: true,
vi: true,
mouse: true,
style: {
selected: { bg: colors.selected, fg: 'white' },
item: { fg: colors.text },
},
});
this.bindKeys();
}
/**
* Bind keyboard shortcuts
*/
private bindKeys(): void {
this.list.key(['enter'], () => {
this.navigateSelected();
});
this.list.key(['s'], () => {
this.toggleStats();
});
this.list.key(['l'], () => {
this.toggleLinks();
});
this.list.key(['r'], () => {
this.refresh();
});
this.list.key(['escape'], () => {
if (this.viewMode !== 'links') {
this.viewMode = 'links';
this.render();
}
});
}
/**
* Set the current entity to show cross-references for
*/
setEntity(entity: CrossReferenceEntity | null): void {
this.currentEntity = entity;
this.refresh();
}
/**
* Set entity by type and ID
*/
setEntityById(type: CrossReferenceEntityType, id: string): void {
const entity = this.manager.getEntity(type, id);
this.setEntity(entity || null);
}
/**
* Refresh the display
*/
refresh(): void {
if (this.viewMode === 'stats') {
this.renderStats();
} else if (this.currentEntity) {
this.renderLinks();
} else {
this.renderOverview();
}
}
/**
* Render links for the current entity
*/
private renderLinks(): void {
if (!this.currentEntity) return;
this.links = [];
const items: string[] = [];
const allLinks = this.manager.getLinksForEntity(
this.currentEntity.type,
this.currentEntity.id
);
for (const link of allLinks) {
const config = RELATIONSHIP_CONFIG[link.relationship] || {
label: link.relationship,
color: colors.text,
};
const isSource = link.sourceType === this.currentEntity.type &&
link.sourceId === this.currentEntity.id;
const arrow = isSource ? '→' : '←';
const targetDisplay = this.getEntityDisplay(link.targetType, link.targetId);
const displayText = `{${config.color}-fg}${config.label}{/} ${arrow} ${targetDisplay}`;
const strengthBar = this.getStrengthBar(link.strength);
items.push(`${displayText} ${strengthBar}`);
this.links.push({ link, displayText });
}
this.list.setItems(items);
this.box.setLabel(` Cross-References: ${this.currentEntity.label} `);
this.box.screen.render();
}
/**
* Render overview of all cross-references
*/
private renderOverview(): void {
const stats = this.manager.getStats();
const items: string[] = [];
items.push('{bold}Cross-Reference Overview{/}');
items.push('');
items.push(`Total Links: {cyan-fg}${stats.totalLinks}{/}`);
items.push(`Total Entities: {green-fg}${stats.totalEntities}{/}`);
items.push('');
items.push('{bold}By Relationship Type:{/}');
for (const [rel, count] of Object.entries(stats.byRelationship)) {
if (count > 0) {
const config = RELATIONSHIP_CONFIG[rel as CrossReferenceRelationship];
const color = config?.color || colors.text;
items.push(` {${color}-fg}${config?.label || rel}{/}: ${count}`);
}
}
items.push('');
items.push('{bold}Most Linked Entities:{/}');
for (const entity of stats.mostLinked.slice(5)) {
items.push(` {bold}${entity.type}{/}: ${entity.label} (${entity.linkCount} links)`);
}
this.list.setItems(items);
this.box.setLabel(' Cross-References ');
this.box.screen.render();
}
/**
* Render statistics view
*/
private renderStats(): void {
const stats = this.manager.getStats();
const items: string[] = [];
items.push('{bold}Cross-Reference Statistics{/}');
items.push('');
items.push('{bold}Links by Type:{/}');
const sortedRels = Object.entries(stats.byRelationship)
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
for (const [rel, count] of sortedRels) {
const config = RELATIONSHIP_CONFIG[rel as CrossReferenceRelationship];
const color = config?.color || colors.text;
const bar = this.getBar(count, stats.totalLinks);
items.push(` {${color}-fg}${(config?.label || rel).padEnd(12)}{/} ${bar} ${count}`);
}
items.push('');
items.push('{bold}Entities by Type:{/}');
for (const [type, count] of Object.entries(stats.byEntityType)) {
if (count > 0) {
items.push(` {bold}${type.padEnd(10)}{/}: ${count}`);
}
}
items.push('');
items.push('{bold}Recent Links:{/}');
for (const link of stats.recentLinks.slice(5)) {
const config = RELATIONSHIP_CONFIG[link.relationship];
const color = config?.color || colors.text;
const sourceDisplay = this.getEntityDisplay(link.sourceType, link.sourceId);
items.push(` {${color}-fg}${config?.label || link.relationship}{/}: ${sourceDisplay}`);
}
this.list.setItems(items);
this.box.setLabel(' Cross-Reference Statistics ');
this.box.screen.render();
}
/**
* Get a display string for an entity
*/
private getEntityDisplay(type: CrossReferenceEntityType, id: string): string {
switch (type) {
case 'worker':
return `{green-fg}${id.slice(0, 8)}{/}`;
case 'file':
const fileName = id.split('/').pop() || id;
return `{cyan-fg}${fileName}{/}`;
case 'bead':
return `{magenta-fg}${id}{/}`;
case 'event':
return `{yellow-fg}${id.slice(0, 12)}...{/}`;
default:
return id.slice(0, 15);
}
}
/**
* Get a visual strength bar
*/
private getStrengthBar(strength: number): string {
const filled = Math.round(strength * 5);
const empty = 5 - filled;
return `{green-fg}${'█'.repeat(filled)}{/}{gray-fg}${'░'.repeat(empty)}{/}`;
}
/**
* Get a proportional bar for statistics
*/
private getBar(value: number, total: number): string {
if (total === 0) return '';
const percent = Math.round((value / total) * 20);
return '█'.repeat(percent) + '░'.repeat(20 - percent);
}
/**
* Navigate to the selected link's target entity
*/
private navigateSelected(): void {
const selected = this.list.selected;
if (selected < 0 || selected >= this.links.length) return;
const linkDisplay = this.links[selected];
const targetEntity = this.manager.getEntity(
linkDisplay.link.targetType,
linkDisplay.link.targetId
);
if (targetEntity) {
this.setEntity(targetEntity);
}
}
/**
* Toggle statistics view
*/
private toggleStats(): void {
if (this.viewMode === 'stats') {
this.viewMode = 'links';
} else {
this.viewMode = 'stats';
}
this.refresh();
}
/**
* Toggle links view
*/
private toggleLinks(): void {
this.viewMode = 'links';
this.refresh();
}
/**
* Find a path to another entity
*/
findPathTo(
targetType: CrossReferenceEntityType,
targetId: string
): void {
if (!this.currentEntity) return;
const path = this.manager.findPath(
this.currentEntity.type,
this.currentEntity.id,
targetType,
targetId
);
if (path) {
this.renderPath(path);
} else {
this.list.setItems([
`{red-fg}No path found to ${targetType}:${targetId}{/}`,
]);
this.box.screen.render();
}
}
/**
* Render a navigation path
*/
private renderPath(path: import('../../types.js').CrossReferencePath): void {
const items: string[] = [];
items.push('{bold}Navigation Path{/}');
items.push('');
items.push(`From: ${this.getEntityDisplay(path.start.type, path.start.id)}`);
items.push(`To: ${this.getEntityDisplay(path.end.type, path.end.id)}`);
items.push(`Length: ${path.length} steps`);
items.push('');
items.push('{bold}Steps:{/}');
for (let i = 0; i < path.steps.length; i++) {
const step = path.steps[i];
const config = RELATIONSHIP_CONFIG[step.relationship];
const color = config?.color || colors.text;
const targetDisplay = this.getEntityDisplay(step.targetType, step.targetId);
items.push(` ${i + 1}. {${color}-fg}${config?.label || step.relationship}{/} → ${targetDisplay}`);
}
items.push('');
items.push(`Description: ${path.description}`);
this.list.setItems(items);
this.box.setLabel(' Navigation Path ');
this.box.screen.render();
}
/**
* Focus this component
*/
focus(): void {
this.list.focus();
}
/**
* Get the underlying blessed element
*/
getElement(): blessed.Widgets.BoxElement {
return this.box;
}
/**
* Show the panel
*/
show(): void {
this.box.show();
this.refresh();
}
/**
* Hide the panel
*/
hide(): void {
this.box.hide();
this.box.screen.render();
}
/**
* Toggle visibility
*/
toggle(): void {
if (this.box.hidden) {
this.show();
} else {
this.hide();
}
}
}
export function createCrossReferencePanel(
options: CrossReferencePanelOptions
): CrossReferencePanel {
return new CrossReferencePanel(options);
}
export default CrossReferencePanel;

View file

@ -64,6 +64,15 @@ export interface WorkerInfo {
/** Whether this worker is involved in any collisions */
hasCollision: boolean;
/** Current bead/task being worked on */
activeBead?: string;
/** Directories this worker is active in */
activeDirectories: string[];
/** All collision types this worker is involved in */
collisionTypes: ('file' | 'bead' | 'task')[];
}
export interface EventFilter {
@ -106,6 +115,90 @@ export interface FileCollision {
isActive: boolean;
}
/**
* Bead collision - when multiple workers work on the same bead/task
*/
export interface BeadCollision {
/** Bead ID being contested */
beadId: string;
/** Workers working on this bead */
workers: string[];
/** Timestamp when collision was detected */
detectedAt: number;
/** Events that triggered the collision */
events: LogEvent[];
/** Whether the collision is still active */
isActive: boolean;
/** Collision severity based on operation types */
severity: 'warning' | 'critical';
}
/**
* Task collision - when workers work on tasks that may conflict
*/
export interface TaskCollision {
/** Type of collision */
type: 'directory' | 'related_files' | 'dependency';
/** Human-readable description */
description: string;
/** Workers involved */
workers: string[];
/** Affected paths/beads */
affectedResources: string[];
/** Timestamp when collision was detected */
detectedAt: number;
/** Whether the collision is still active */
isActive: boolean;
/** Risk level */
riskLevel: 'low' | 'medium' | 'high';
}
/**
* Collision alert for user notification
*/
export interface CollisionAlert {
/** Unique alert ID */
id: string;
/** Alert type */
type: 'file' | 'bead' | 'task';
/** Severity level */
severity: 'info' | 'warning' | 'error' | 'critical';
/** Human-readable title */
title: string;
/** Detailed description */
description: string;
/** Workers involved */
workers: string[];
/** Timestamp when alert was generated */
timestamp: number;
/** Whether the alert has been acknowledged */
acknowledged: boolean;
/** Related collision data */
collision: FileCollision | BeadCollision | TaskCollision;
/** Suggested resolution */
suggestion?: string;
}
export interface EventStore {
/** Add an event to the store */
add(event: LogEvent): void;
@ -536,3 +629,166 @@ export interface DagStats {
/** Beads on critical path */
criticalPathBeads: number;
}
// ============================================
// Cross-Reference Types
// ============================================
/**
* Type of entity that can be cross-referenced
*/
export type CrossReferenceEntityType = 'event' | 'bead' | 'file' | 'worker' | 'session';
/**
* A single cross-reference link
*/
export interface CrossReferenceLink {
/** Unique link ID */
id: string;
/** Source entity type */
sourceType: CrossReferenceEntityType;
/** Source entity ID */
sourceId: string;
/** Target entity type */
targetType: CrossReferenceEntityType;
/** Target entity ID */
targetId: string;
/** Relationship type */
relationship: CrossReferenceRelationship;
/** Strength of the relationship (0-1) */
strength: number;
/** When this link was detected */
detectedAt: number;
/** Optional context about why this link exists */
context?: string;
}
/**
* Types of relationships between entities
*/
export type CrossReferenceRelationship =
| 'same_bead' // Events working on the same bead/task
| 'same_file' // Events modifying the same file
| 'same_worker' // Events from the same worker
| 'temporal_proximity' // Events happening close together in time
| 'same_session' // Events in the same worker session
| 'dependency' // One bead depends on another
| 'collision' // Workers colliding on the same file
| 'parent_child' // Hierarchical relationship
| 'error_related' // Events related to the same error
| 'tool_sequence'; // Tool calls that form a logical sequence
/**
* A cross-reference entity with its links
*/
export interface CrossReferenceEntity {
/** Entity type */
type: CrossReferenceEntityType;
/** Entity ID */
id: string;
/** Human-readable label */
label: string;
/** All links from this entity */
outgoingLinks: CrossReferenceLink[];
/** All links to this entity */
incomingLinks: CrossReferenceLink[];
/** Related entities grouped by type */
relatedEntities: Map<CrossReferenceEntityType, CrossReferenceLink[]>;
/** Total link count */
linkCount: number;
/** Most recent link timestamp */
lastLinkedAt: number;
/** First seen timestamp */
firstSeen: number;
/** Number of occurrences */
occurrenceCount: number;
}
/**
* Options for cross-reference queries
*/
export interface CrossReferenceQueryOptions {
/** Filter by source entity type */
sourceType?: CrossReferenceEntityType;
/** Filter by target entity type */
targetType?: CrossReferenceEntityType;
/** Filter by relationship type */
relationship?: CrossReferenceRelationship;
/** Minimum relationship strength */
minStrength?: number;
/** Time range start */
since?: number;
/** Time range end */
until?: number;
/** Maximum results */
limit?: number;
}
/** Alias for backward compatibility */
export type CrossReferenceFilter = CrossReferenceQueryOptions;
/**
* Statistics about cross-references
*/
export interface CrossReferenceStats {
/** Total links tracked */
totalLinks: number;
/** Total entities tracked */
totalEntities: number;
/** Links by relationship type */
byRelationship: Record<CrossReferenceRelationship, number>;
/** Entities by type */
byEntityType: Record<CrossReferenceEntityType, number>;
/** Most linked entities */
mostLinked: CrossReferenceEntity[];
/** Recent links */
recentLinks: CrossReferenceLink[];
}
/**
* A navigation path through cross-references
*/
export interface CrossReferencePath {
/** Starting entity */
start: CrossReferenceEntity;
/** Ending entity */
end: CrossReferenceEntity;
/** Path steps */
steps: CrossReferenceLink[];
/** Total path length */
length: number;
/** Path description */
description: string;
}

View file

@ -0,0 +1,399 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
CrossReferenceLink,
CrossReferenceEntity,
CrossReferenceStats,
CrossReferenceEntityType,
CrossReferenceRelationship,
CrossReferencePath,
} from '../types';
interface CrossReferencePanelProps {
selectedWorker?: string | null;
selectedBead?: string | null;
selectedFile?: string | null;
onNavigate?: (type: CrossReferenceEntityType, id: string) => void;
}
const RELATIONSHIP_CONFIG: Record<CrossReferenceRelationship, { label: string; color: string }> = {
same_bead: { label: 'Same Task', color: '#9333ea' },
same_file: { label: 'Same File', color: '#0891b2' },
same_worker: { label: 'Same Worker', color: '#16a34a' },
temporal_proximity: { label: 'Time Proximity', color: '#ca8a04' },
same_session: { label: 'Same Session', color: '#2563eb' },
dependency: { label: 'Dependency', color: '#ea580c' },
collision: { label: 'Collision', color: '#dc2626' },
parent_child: { label: 'Parent/Child', color: '#7c3aed' },
error_related: { label: 'Error Related', color: '#dc2626' },
tool_sequence: { label: 'Tool Sequence', color: '#0d9488' },
};
const ENTITY_COLORS: Record<CrossReferenceEntityType, string> = {
event: '#fbbf24',
worker: '#16a34a',
file: '#0891b2',
bead: '#9333ea',
session: '#2563eb',
};
const CrossReferencePanel: React.FC<CrossReferencePanelProps> = ({
selectedWorker,
selectedBead,
selectedFile,
onNavigate,
}) => {
const [stats, setStats] = useState<CrossReferenceStats | null>(null);
const [links, setLinks] = useState<CrossReferenceLink[]>([]);
const [currentEntity, setCurrentEntity] = useState<CrossReferenceEntity | null>(null);
const [viewMode, setViewMode] = useState<'stats' | 'links' | 'entity'>('stats');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pathResult, setPathResult] = useState<CrossReferencePath | null>(null);
const fetchStats = useCallback(async () => {
try {
const response = await fetch('/api/xref/stats');
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch stats');
}
}, []);
const fetchLinks = useCallback(async (
sourceType?: CrossReferenceEntityType,
sourceId?: string
) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (sourceType) params.set('sourceType', sourceType);
if (sourceId) params.set('sourceId', sourceId);
params.set('limit', '50');
const response = await fetch(`/api/xref/links?${params.toString()}`);
if (!response.ok) throw new Error('Failed to fetch links');
const data = await response.json();
setLinks(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch links');
} finally {
setLoading(false);
}
}, []);
const fetchEntity = useCallback(async (type: CrossReferenceEntityType, id: string) => {
setLoading(true);
try {
const response = await fetch(`/api/xref/entities/${type}/${encodeURIComponent(id)}`);
if (!response.ok) {
if (response.status === 404) {
setCurrentEntity(null);
return;
}
throw new Error('Failed to fetch entity');
}
const data = await response.json();
setCurrentEntity(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch entity');
} finally {
setLoading(false);
}
}, []);
const findPath = useCallback(async (
sourceType: CrossReferenceEntityType,
sourceId: string,
targetType: CrossReferenceEntityType,
targetId: string
) => {
setLoading(true);
try {
const params = new URLSearchParams({
sourceType,
sourceId,
targetType,
targetId,
});
const response = await fetch(`/api/xref/path?${params.toString()}`);
if (!response.ok) {
if (response.status === 404) {
setPathResult(null);
return;
}
throw new Error('Failed to find path');
}
const data = await response.json();
setPathResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to find path');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
useEffect(() => {
if (selectedWorker) {
fetchEntity('worker', selectedWorker);
setViewMode('entity');
} else if (selectedBead) {
fetchEntity('bead', selectedBead);
setViewMode('entity');
} else if (selectedFile) {
fetchEntity('file', selectedFile);
setViewMode('entity');
}
}, [selectedWorker, selectedBead, selectedFile, fetchEntity]);
const handleNavigate = (type: CrossReferenceEntityType, id: string) => {
fetchEntity(type, id);
onNavigate?.(type, id);
};
const renderStrengthBar = (strength: number) => {
const filled = Math.round(strength * 5);
const empty = 5 - filled;
return (
<span className="strength-bar">
{'█'.repeat(filled)}{'░'.repeat(empty)}
</span>
);
};
const renderRelationshipBadge = (relationship: CrossReferenceRelationship) => {
const config = RELATIONSHIP_CONFIG[relationship] || {
label: relationship,
color: '#6b7280',
};
return (
<span
className="relationship-badge"
style={{ backgroundColor: config.color }}
>
{config.label}
</span>
);
};
const renderEntityBadge = (type: CrossReferenceEntityType, id: string) => {
const color = ENTITY_COLORS[type] || '#6b7280';
const displayId = type === 'file'
? id.split('/').pop()
: type === 'worker'
? id.slice(0, 8)
: id.slice(0, 12);
return (
<span
className="entity-badge"
style={{ borderColor: color, color }}
onClick={() => handleNavigate(type, id)}
>
{type}: {displayId}
</span>
);
};
const renderStatsView = () => (
<div className="xref-stats">
<div className="stats-header">
<h3>Cross-Reference Statistics</h3>
<button onClick={() => setViewMode('links')}>View All Links</button>
</div>
<div className="stats-overview">
<div className="stat-card">
<div className="stat-value">{stats?.totalLinks || 0}</div>
<div className="stat-label">Total Links</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats?.totalEntities || 0}</div>
<div className="stat-label">Entities Tracked</div>
</div>
</div>
<div className="stats-section">
<h4>By Relationship Type</h4>
<div className="relationship-bars">
{stats && Object.entries(stats.byRelationship)
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1])
.map(([rel, count]) => {
const config = RELATIONSHIP_CONFIG[rel as CrossReferenceRelationship];
const percent = stats.totalLinks > 0
? (count / stats.totalLinks) * 100
: 0;
return (
<div key={rel} className="relationship-bar-row">
<span
className="relationship-bar-label"
style={{ color: config?.color }}
>
{config?.label || rel}
</span>
<div className="relationship-bar-container">
<div
className="relationship-bar-fill"
style={{
width: `${percent}%`,
backgroundColor: config?.color,
}}
/>
</div>
<span className="relationship-bar-count">{count}</span>
</div>
);
})}
</div>
</div>
<div className="stats-section">
<h4>Most Linked Entities</h4>
<div className="most-linked-list">
{stats?.mostLinked.slice(5).map((entity, i) => (
<div
key={i}
className="most-linked-item"
onClick={() => handleNavigate(entity.type, entity.id)}
>
{renderEntityBadge(entity.type, entity.id)}
<span className="link-count">{entity.linkCount} links</span>
</div>
))}
</div>
</div>
<div className="stats-section">
<h4>Recent Links</h4>
<div className="recent-links-list">
{stats?.recentLinks.slice(10).map((link) => (
<div key={link.id} className="recent-link-item">
{renderRelationshipBadge(link.relationship)}
{renderEntityBadge(link.sourceType, link.sourceId)}
<span className="arrow"></span>
{renderEntityBadge(link.targetType, link.targetId)}
</div>
))}
</div>
</div>
</div>
);
const renderEntityView = () => (
<div className="xref-entity">
<div className="entity-header">
<button onClick={() => setViewMode('stats')}> Back to Stats</button>
{currentEntity && (
<h3>
{renderEntityBadge(currentEntity.type, currentEntity.id)}
<span className="entity-stats">
{currentEntity.linkCount} links · {currentEntity.occurrenceCount} occurrences
</span>
</h3>
)}
</div>
{currentEntity ? (
<div className="entity-links">
<h4>Related Entities</h4>
<div className="links-list">
{links.filter(l =>
l.sourceId === currentEntity.id || l.targetId === currentEntity.id
).map((link) => {
const isSource = link.sourceId === currentEntity.id;
const targetType = isSource ? link.targetType : link.sourceType;
const targetId = isSource ? link.targetId : link.sourceId;
return (
<div key={link.id} className="link-item">
<div className="link-relationship">
{renderRelationshipBadge(link.relationship)}
{renderStrengthBar(link.strength)}
</div>
<div className="link-target">
<span className="arrow">{isSource ? '→' : '←'}</span>
{renderEntityBadge(targetType, targetId)}
</div>
{link.context && (
<div className="link-context">{link.context}</div>
)}
</div>
);
})}
</div>
</div>
) : (
<div className="entity-not-found">
Entity not found. It may not have been tracked yet.
</div>
)}
{pathResult && (
<div className="path-result">
<h4>Navigation Path</h4>
<div className="path-steps">
{pathResult.steps.map((step, i) => (
<div key={i} className="path-step">
<span className="step-number">{i + 1}</span>
{renderRelationshipBadge(step.relationship)}
{renderEntityBadge(step.targetType, step.targetId)}
</div>
))}
</div>
<div className="path-description">{pathResult.description}</div>
</div>
)}
</div>
);
const renderLinksView = () => (
<div className="xref-links">
<div className="links-header">
<button onClick={() => setViewMode('stats')}> Back to Stats</button>
<h3>All Cross-Reference Links</h3>
</div>
{loading ? (
<div className="loading">Loading...</div>
) : (
<div className="links-list">
{links.map((link) => (
<div key={link.id} className="link-item">
<div className="link-relationship">
{renderRelationshipBadge(link.relationship)}
{renderStrengthBar(link.strength)}
</div>
<div className="link-entities">
{renderEntityBadge(link.sourceType, link.sourceId)}
<span className="arrow"></span>
{renderEntityBadge(link.targetType, link.targetId)}
</div>
{link.context && (
<div className="link-context">{link.context}</div>
)}
</div>
))}
</div>
)}
</div>
);
return (
<div className="cross-reference-panel">
{error && <div className="error-message">{error}</div>}
{loading && <div className="loading-overlay">Loading...</div>}
{viewMode === 'stats' && renderStatsView()}
{viewMode === 'entity' && renderEntityView()}
{viewMode === 'links' && renderLinksView()}
</div>
);
};
export default CrossReferencePanel;

View file

@ -35,3 +35,56 @@ export interface WebSocketMessage {
collisions?: FileCollision[];
} | LogEvent | FileCollision;
}
// Cross-Reference Types
export type CrossReferenceEntityType = 'event' | 'bead' | 'file' | 'worker' | 'session';
export type CrossReferenceRelationship =
| 'same_bead'
| 'same_file'
| 'same_worker'
| 'temporal_proximity'
| 'same_session'
| 'dependency'
| 'collision'
| 'parent_child'
| 'error_related'
| 'tool_sequence';
export interface CrossReferenceLink {
id: string;
sourceType: CrossReferenceEntityType;
sourceId: string;
targetType: CrossReferenceEntityType;
targetId: string;
relationship: CrossReferenceRelationship;
strength: number;
detectedAt: number;
context?: string;
}
export interface CrossReferenceEntity {
type: CrossReferenceEntityType;
id: string;
label: string;
linkCount: number;
lastLinkedAt: number;
firstSeen: number;
occurrenceCount: number;
}
export interface CrossReferenceStats {
totalLinks: number;
totalEntities: number;
byRelationship: Record<CrossReferenceRelationship, number>;
byEntityType: Record<CrossReferenceEntityType, number>;
mostLinked: CrossReferenceEntity[];
recentLinks: CrossReferenceLink[];
}
export interface CrossReferencePath {
start: CrossReferenceEntity;
end: CrossReferenceEntity;
steps: CrossReferenceLink[];
length: number;
description: string;
}

View file

@ -10,8 +10,9 @@ import { EventEmitter } from 'events';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { WebSocketServer, WebSocket } from 'ws';
import { LogEvent, EventFilter } from '../types.js';
import { LogEvent, EventFilter, CrossReferenceEntityType, CrossReferenceRelationship } from '../types.js';
import { InMemoryEventStore } from '../store.js';
import { CrossReferenceManager, getCrossReferenceManager } from '../crossReferenceManager.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -124,6 +125,97 @@ export function createWebServer(options: WebServerOptions): WebServer {
res.json(collisions);
});
// ============================================
// 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();
res.json(stats);
});
// Get all cross-reference links
app.get('/api/xref/links', (req: Request, res: Response) => {
const sourceType = req.query.sourceType as CrossReferenceEntityType | undefined;
const targetType = req.query.targetType as CrossReferenceEntityType | undefined;
const relationship = req.query.relationship as CrossReferenceRelationship | undefined;
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({
sourceType,
targetType,
relationship,
minStrength,
limit,
});
res.json(links);
});
// Get all tracked entities
app.get('/api/xref/entities', (_req: Request, res: Response) => {
const entities = xrefManager.getAllEntities();
res.json(entities);
});
// Get a specific entity
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);
if (!entity) {
res.status(404).json({ error: 'Entity not found' });
return;
}
res.json(entity);
});
// Get links for a specific entity
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);
res.json(links);
});
// Get linked entities for a specific entity
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);
res.json(related);
});
// Find a navigation path between two entities
app.get('/api/xref/path', (req: Request, res: Response) => {
const sourceType = req.query.sourceType as CrossReferenceEntityType;
const sourceId = req.query.sourceId as string;
const targetType = req.query.targetType as CrossReferenceEntityType;
const targetId = req.query.targetId as string;
const maxDepth = req.query.maxDepth ? parseInt(req.query.maxDepth as string) : 5;
if (!sourceType || !sourceId || !targetType || !targetId) {
res.status(400).json({ error: 'Missing required parameters: sourceType, sourceId, targetType, targetId' });
return;
}
const path = xrefManager.findPath(sourceType, sourceId, targetType, targetId, maxDepth);
if (!path) {
res.status(404).json({ error: 'No path found between entities' });
return;
}
res.json(path);
});
// Serve static frontend files
const staticPath = join(__dirname, '..', 'web');
app.use(express.static(staticPath));