- 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>
329 lines
8.2 KiB
TypeScript
329 lines
8.2 KiB
TypeScript
/**
|
|
* 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;
|