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:
parent
fa49d5329d
commit
5fab75708f
9 changed files with 2785 additions and 7 deletions
|
|
@ -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"}]}
|
||||
|
|
|
|||
766
src/crossReferenceManager.ts
Normal file
766
src/crossReferenceManager.ts
Normal 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;
|
||||
438
src/store.ts
438
src/store.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
329
src/tui/components/CollisionAlert.ts
Normal file
329
src/tui/components/CollisionAlert.ts
Normal 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;
|
||||
453
src/tui/components/CrossReferencePanel.ts
Normal file
453
src/tui/components/CrossReferencePanel.ts
Normal 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;
|
||||
256
src/types.ts
256
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
399
src/web/frontend/src/components/CrossReferencePanel.tsx
Normal file
399
src/web/frontend/src/components/CrossReferencePanel.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue