nucleus-mcp 1.0.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -4
- package/.github/workflows/ci.yml +0 -72
- package/.smithery/shttp/module.js +0 -3
- package/.smithery/shttp/module.js.map +0 -7
- package/.smithery/stdio/index.cjs +0 -74
- package/.smithery/stdio/index.cjs.map +0 -7
- package/CHANGELOG.md +0 -65
- package/CODE_OF_CONDUCT.md +0 -34
- package/COMPARISON.md +0 -20
- package/CONTRIBUTING.md +0 -137
- package/DEVELOPMENT.md +0 -109
- package/LICENSE +0 -21
- package/README.md +0 -319
- package/ROADMAP.md +0 -24
- package/SECURITY.md +0 -88
- package/docs/COMMUNITY_FAQ.md +0 -48
- package/docs/COMPETITIVE_ANALYSIS.md +0 -90
- package/docs/DEMO_SCRIPT.md +0 -86
- package/docs/ENTERPRISE.md +0 -169
- package/docs/FAQ.md +0 -228
- package/docs/QUICK_START.md +0 -155
- package/docs/README.md +0 -67
- package/docs/RECURSIVE_SNAP_DEMO.gif +0 -0
- package/docs/social-preview-hq.png +0 -0
- package/docs/social-preview.png +0 -0
- package/examples/basic_usage.py +0 -84
- package/package.json.real +0 -24
- package/pyproject.toml +0 -50
- package/registry-server.json +0 -20
- package/scripts/demo_governance.py +0 -109
- package/scripts/sse_bridge.py +0 -87
- package/scripts/verify_windows_paths.py +0 -48
- package/server.json +0 -20
- package/src/mcp_server_nucleus/__init__.py +0 -8583
- package/src/mcp_server_nucleus/__main__.py +0 -9
- package/src/mcp_server_nucleus/cli.py +0 -1225
- package/src/mcp_server_nucleus/commitment_core.py +0 -146
- package/src/mcp_server_nucleus/commitment_ledger.py +0 -841
- package/src/mcp_server_nucleus/core/__init__.py +0 -6
- package/src/mcp_server_nucleus/core/orchestrator.py +0 -9
- package/src/mcp_server_nucleus/core/tool_registration_impl.py +0 -99
- package/src/mcp_server_nucleus/daemon/safety/lock.py +0 -121
- package/src/mcp_server_nucleus/deployment/render_integration.py +0 -4
- package/src/mcp_server_nucleus/hypervisor/__init__.py +0 -0
- package/src/mcp_server_nucleus/hypervisor/injector.py +0 -83
- package/src/mcp_server_nucleus/hypervisor/locker.py +0 -130
- package/src/mcp_server_nucleus/hypervisor/watchdog.py +0 -71
- package/src/mcp_server_nucleus/runtime/__init__.py +0 -58
- package/src/mcp_server_nucleus/runtime/agent.py +0 -517
- package/src/mcp_server_nucleus/runtime/agent_pool.py +0 -797
- package/src/mcp_server_nucleus/runtime/agents/base.py +0 -34
- package/src/mcp_server_nucleus/runtime/agents/oracle/manifest.json +0 -28
- package/src/mcp_server_nucleus/runtime/auth.py +0 -98
- package/src/mcp_server_nucleus/runtime/autopilot.py +0 -1057
- package/src/mcp_server_nucleus/runtime/broker.py +0 -126
- package/src/mcp_server_nucleus/runtime/budget.py +0 -153
- package/src/mcp_server_nucleus/runtime/capabilities/base.py +0 -20
- package/src/mcp_server_nucleus/runtime/capabilities/brain_ops.py +0 -253
- package/src/mcp_server_nucleus/runtime/capabilities/code_ops.py +0 -192
- package/src/mcp_server_nucleus/runtime/capabilities/depth_tracker.py +0 -81
- package/src/mcp_server_nucleus/runtime/capabilities/feature_map.py +0 -274
- package/src/mcp_server_nucleus/runtime/capabilities/marketing_engine.py +0 -205
- package/src/mcp_server_nucleus/runtime/capabilities/memory_ops.py +0 -79
- package/src/mcp_server_nucleus/runtime/capabilities/proof_system.py +0 -127
- package/src/mcp_server_nucleus/runtime/capabilities/render_ops.py +0 -80
- package/src/mcp_server_nucleus/runtime/capabilities/render_poller_cap.py +0 -266
- package/src/mcp_server_nucleus/runtime/capabilities/self_healing.py +0 -110
- package/src/mcp_server_nucleus/runtime/capabilities/strategy.py +0 -138
- package/src/mcp_server_nucleus/runtime/capabilities/synthesizer.py +0 -134
- package/src/mcp_server_nucleus/runtime/capabilities/web_ops.py +0 -94
- package/src/mcp_server_nucleus/runtime/common.py +0 -134
- package/src/mcp_server_nucleus/runtime/context_manager.py +0 -342
- package/src/mcp_server_nucleus/runtime/context_rules.json +0 -86
- package/src/mcp_server_nucleus/runtime/crdt_task_store.py +0 -349
- package/src/mcp_server_nucleus/runtime/critic.py +0 -150
- package/src/mcp_server_nucleus/runtime/daemon.py +0 -142
- package/src/mcp_server_nucleus/runtime/dashboard.py +0 -1087
- package/src/mcp_server_nucleus/runtime/depth_ops.py +0 -339
- package/src/mcp_server_nucleus/runtime/event_ops.py +0 -103
- package/src/mcp_server_nucleus/runtime/event_stream.py +0 -213
- package/src/mcp_server_nucleus/runtime/export.py +0 -64
- package/src/mcp_server_nucleus/runtime/factory.py +0 -594
- package/src/mcp_server_nucleus/runtime/federation.py +0 -1040
- package/src/mcp_server_nucleus/runtime/fetcher.py +0 -92
- package/src/mcp_server_nucleus/runtime/file_monitor.py +0 -208
- package/src/mcp_server_nucleus/runtime/firestore_bridge.py +0 -91
- package/src/mcp_server_nucleus/runtime/gcloud_ops.py +0 -186
- package/src/mcp_server_nucleus/runtime/hooks.py +0 -246
- package/src/mcp_server_nucleus/runtime/identity/gatekeeper.py +0 -106
- package/src/mcp_server_nucleus/runtime/identity/keygen.py +0 -147
- package/src/mcp_server_nucleus/runtime/identity/manifest.py +0 -62
- package/src/mcp_server_nucleus/runtime/identity/trust.py +0 -27
- package/src/mcp_server_nucleus/runtime/inspector.py +0 -74
- package/src/mcp_server_nucleus/runtime/installer.py +0 -81
- package/src/mcp_server_nucleus/runtime/ipc_auth.py +0 -377
- package/src/mcp_server_nucleus/runtime/lifecycle.py +0 -129
- package/src/mcp_server_nucleus/runtime/llm_client.py +0 -408
- package/src/mcp_server_nucleus/runtime/llm_tiers.json +0 -88
- package/src/mcp_server_nucleus/runtime/locking.py +0 -228
- package/src/mcp_server_nucleus/runtime/loops/fixer.py +0 -110
- package/src/mcp_server_nucleus/runtime/memory.py +0 -158
- package/src/mcp_server_nucleus/runtime/mounter.py +0 -210
- package/src/mcp_server_nucleus/runtime/mounter_ops.py +0 -250
- package/src/mcp_server_nucleus/runtime/nuke_protocol.py +0 -147
- package/src/mcp_server_nucleus/runtime/orchestrator.py +0 -370
- package/src/mcp_server_nucleus/runtime/orchestrator_unified.py +0 -263
- package/src/mcp_server_nucleus/runtime/orchestrator_v3.py +0 -617
- package/src/mcp_server_nucleus/runtime/plugin_loader.py +0 -84
- package/src/mcp_server_nucleus/runtime/policy.py +0 -117
- package/src/mcp_server_nucleus/runtime/profiling.py +0 -219
- package/src/mcp_server_nucleus/runtime/prometheus.py +0 -286
- package/src/mcp_server_nucleus/runtime/proposals.py +0 -101
- package/src/mcp_server_nucleus/runtime/publisher.py +0 -109
- package/src/mcp_server_nucleus/runtime/pulse.py +0 -516
- package/src/mcp_server_nucleus/runtime/registry.py +0 -82
- package/src/mcp_server_nucleus/runtime/render_ops.py +0 -83
- package/src/mcp_server_nucleus/runtime/schema_gen.py +0 -140
- package/src/mcp_server_nucleus/runtime/session_ops.py +0 -224
- package/src/mcp_server_nucleus/runtime/stdio_server.py +0 -633
- package/src/mcp_server_nucleus/runtime/storage.py +0 -83
- package/src/mcp_server_nucleus/runtime/strategy.py +0 -91
- package/src/mcp_server_nucleus/runtime/swarm.py +0 -170
- package/src/mcp_server_nucleus/runtime/sync_ops.py +0 -878
- package/src/mcp_server_nucleus/runtime/task_ingestion.py +0 -1020
- package/src/mcp_server_nucleus/runtime/task_ops.py +0 -449
- package/src/mcp_server_nucleus/runtime/task_scheduler.py +0 -488
- package/src/mcp_server_nucleus/runtime/team.py +0 -59
- package/src/mcp_server_nucleus/runtime/triggers.py +0 -237
- package/src/mcp_server_nucleus/runtime/vector_store.py +0 -125
- package/src/mcp_server_nucleus/runtime/watcher.py +0 -112
- package/src/mcp_server_nucleus/tool_tiers.py +0 -243
- package/tests/__init__.py +0 -1
- package/tests/conftest.py +0 -42
- package/tests/test_core.py +0 -235
- package/tests/test_search.py +0 -69
|
@@ -1,1087 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
DashboardEngine - Enterprise-Grade Orchestration Dashboard
|
|
3
|
-
Real-time visibility into all NOP V3.1 components.
|
|
4
|
-
|
|
5
|
-
Features:
|
|
6
|
-
- 6 metric categories, 25+ metrics
|
|
7
|
-
- ASCII, JSON, Mermaid output formats
|
|
8
|
-
- Alert engine with configurable thresholds
|
|
9
|
-
- Trend analysis with 7-day JSONL persistence
|
|
10
|
-
- Snapshot creation and comparison
|
|
11
|
-
- <100ms render time for 10K tasks
|
|
12
|
-
- Graceful degradation on component failure
|
|
13
|
-
|
|
14
|
-
Scales: 10K tasks, 100 agents, 100 concurrent viewers
|
|
15
|
-
|
|
16
|
-
Author: NOP V3.1 - January 2026
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
import json
|
|
20
|
-
import time
|
|
21
|
-
import threading
|
|
22
|
-
import hashlib
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import Dict, List, Optional, Any, Tuple
|
|
25
|
-
from dataclasses import dataclass, field
|
|
26
|
-
from enum import Enum
|
|
27
|
-
from datetime import datetime, timedelta
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class AlertLevel(str, Enum):
|
|
31
|
-
"""Alert severity levels."""
|
|
32
|
-
CRITICAL = "critical"
|
|
33
|
-
WARNING = "warning"
|
|
34
|
-
INFO = "info"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class MetricCategory(str, Enum):
|
|
38
|
-
"""Metric category types."""
|
|
39
|
-
AGENTS = "agents"
|
|
40
|
-
TASKS = "tasks"
|
|
41
|
-
INGESTION = "ingestion"
|
|
42
|
-
COST = "cost"
|
|
43
|
-
DEPS = "deps"
|
|
44
|
-
SYSTEM = "system"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class OutputFormat(str, Enum):
|
|
48
|
-
"""Supported output formats."""
|
|
49
|
-
ASCII = "ascii"
|
|
50
|
-
JSON = "json"
|
|
51
|
-
MERMAID = "mermaid"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@dataclass
|
|
55
|
-
class Alert:
|
|
56
|
-
"""Alert data structure."""
|
|
57
|
-
level: AlertLevel
|
|
58
|
-
metric: str
|
|
59
|
-
message: str
|
|
60
|
-
value: float
|
|
61
|
-
threshold: float
|
|
62
|
-
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
|
63
|
-
|
|
64
|
-
def to_dict(self) -> Dict:
|
|
65
|
-
return {
|
|
66
|
-
"level": self.level.value,
|
|
67
|
-
"metric": self.metric,
|
|
68
|
-
"message": self.message,
|
|
69
|
-
"value": self.value,
|
|
70
|
-
"threshold": self.threshold,
|
|
71
|
-
"timestamp": self.timestamp,
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@dataclass
|
|
76
|
-
class Snapshot:
|
|
77
|
-
"""Dashboard snapshot for comparison."""
|
|
78
|
-
id: str
|
|
79
|
-
name: str
|
|
80
|
-
timestamp: str
|
|
81
|
-
metrics: Dict[str, Any]
|
|
82
|
-
alerts: List[Dict]
|
|
83
|
-
|
|
84
|
-
def to_dict(self) -> Dict:
|
|
85
|
-
return {
|
|
86
|
-
"id": self.id,
|
|
87
|
-
"name": self.name,
|
|
88
|
-
"timestamp": self.timestamp,
|
|
89
|
-
"metrics": self.metrics,
|
|
90
|
-
"alerts": self.alerts,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class MetricsCache:
|
|
95
|
-
"""Cache with configurable TTL for dashboard metrics."""
|
|
96
|
-
|
|
97
|
-
def __init__(self, ttl_ms: int = 100):
|
|
98
|
-
self.ttl_ms = ttl_ms
|
|
99
|
-
self.cache: Dict[str, Tuple[float, Any]] = {}
|
|
100
|
-
self.lock = threading.RLock()
|
|
101
|
-
|
|
102
|
-
def get(self, key: str) -> Optional[Any]:
|
|
103
|
-
"""Get cached value if not expired."""
|
|
104
|
-
with self.lock:
|
|
105
|
-
if key in self.cache:
|
|
106
|
-
ts, value = self.cache[key]
|
|
107
|
-
if (time.time() - ts) * 1000 < self.ttl_ms:
|
|
108
|
-
return value
|
|
109
|
-
del self.cache[key]
|
|
110
|
-
return None
|
|
111
|
-
|
|
112
|
-
def set(self, key: str, value: Any) -> None:
|
|
113
|
-
"""Set cached value with current timestamp."""
|
|
114
|
-
with self.lock:
|
|
115
|
-
self.cache[key] = (time.time(), value)
|
|
116
|
-
|
|
117
|
-
def invalidate(self, key: str = None) -> None:
|
|
118
|
-
"""Invalidate cache entry or all entries."""
|
|
119
|
-
with self.lock:
|
|
120
|
-
if key:
|
|
121
|
-
self.cache.pop(key, None)
|
|
122
|
-
else:
|
|
123
|
-
self.cache.clear()
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
class AlertEngine:
|
|
127
|
-
"""Alert engine with configurable thresholds."""
|
|
128
|
-
|
|
129
|
-
DEFAULT_THRESHOLDS = {
|
|
130
|
-
"agents.exhausted_ratio": {"warning": 0.5, "critical": 0.9},
|
|
131
|
-
"agents.utilization": {"warning": 0.9, "critical": 0.99},
|
|
132
|
-
"tasks.pending": {"warning": 100, "critical": 500},
|
|
133
|
-
"tasks.blocked_ratio": {"warning": 0.3, "critical": 0.5},
|
|
134
|
-
"cost.budget_remaining_ratio": {"warning": 0.2, "critical": 0.05},
|
|
135
|
-
"deps.max_depth": {"warning": 5, "critical": 10},
|
|
136
|
-
"deps.circular": {"warning": 1, "critical": 1},
|
|
137
|
-
"system.error_rate": {"warning": 5, "critical": 20},
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
def __init__(self):
|
|
141
|
-
self.thresholds = dict(self.DEFAULT_THRESHOLDS)
|
|
142
|
-
self.active_alerts: List[Alert] = []
|
|
143
|
-
|
|
144
|
-
def set_threshold(self, metric: str, level: str, value: float) -> None:
|
|
145
|
-
"""Set custom threshold for metric."""
|
|
146
|
-
if metric not in self.thresholds:
|
|
147
|
-
self.thresholds[metric] = {}
|
|
148
|
-
self.thresholds[metric][level] = value
|
|
149
|
-
|
|
150
|
-
def check(self, metrics: Dict[str, Any]) -> List[Alert]:
|
|
151
|
-
"""Check all metrics against thresholds."""
|
|
152
|
-
alerts = []
|
|
153
|
-
|
|
154
|
-
# Agent alerts
|
|
155
|
-
if "agents" in metrics:
|
|
156
|
-
agents = metrics["agents"]
|
|
157
|
-
total = agents.get("total", 0)
|
|
158
|
-
exhausted = agents.get("exhausted", 0)
|
|
159
|
-
|
|
160
|
-
if total > 0:
|
|
161
|
-
exhausted_ratio = exhausted / total
|
|
162
|
-
alert = self._check_threshold(
|
|
163
|
-
"agents.exhausted_ratio", exhausted_ratio,
|
|
164
|
-
f"{int(exhausted_ratio*100)}% of agents exhausted"
|
|
165
|
-
)
|
|
166
|
-
if alert:
|
|
167
|
-
alerts.append(alert)
|
|
168
|
-
|
|
169
|
-
utilization = agents.get("utilization", 0)
|
|
170
|
-
alert = self._check_threshold(
|
|
171
|
-
"agents.utilization", utilization,
|
|
172
|
-
f"Agent pool at {int(utilization*100)}% capacity"
|
|
173
|
-
)
|
|
174
|
-
if alert:
|
|
175
|
-
alerts.append(alert)
|
|
176
|
-
|
|
177
|
-
# Task alerts
|
|
178
|
-
if "tasks" in metrics:
|
|
179
|
-
tasks = metrics["tasks"]
|
|
180
|
-
pending = tasks.get("pending", 0)
|
|
181
|
-
|
|
182
|
-
alert = self._check_threshold(
|
|
183
|
-
"tasks.pending", pending,
|
|
184
|
-
f"{pending} tasks pending in queue"
|
|
185
|
-
)
|
|
186
|
-
if alert:
|
|
187
|
-
alerts.append(alert)
|
|
188
|
-
|
|
189
|
-
total = tasks.get("total", 0)
|
|
190
|
-
blocked = tasks.get("blocked", 0)
|
|
191
|
-
if total > 0:
|
|
192
|
-
blocked_ratio = blocked / total
|
|
193
|
-
alert = self._check_threshold(
|
|
194
|
-
"tasks.blocked_ratio", blocked_ratio,
|
|
195
|
-
f"{int(blocked_ratio*100)}% of tasks blocked"
|
|
196
|
-
)
|
|
197
|
-
if alert:
|
|
198
|
-
alerts.append(alert)
|
|
199
|
-
|
|
200
|
-
# Cost alerts
|
|
201
|
-
if "cost" in metrics:
|
|
202
|
-
cost = metrics["cost"]
|
|
203
|
-
budget = cost.get("budget", 0)
|
|
204
|
-
remaining = cost.get("remaining", 0)
|
|
205
|
-
|
|
206
|
-
if budget > 0:
|
|
207
|
-
remaining_ratio = remaining / budget
|
|
208
|
-
alert = self._check_threshold(
|
|
209
|
-
"cost.budget_remaining_ratio", remaining_ratio,
|
|
210
|
-
f"Only {int(remaining_ratio*100)}% budget remaining",
|
|
211
|
-
inverse=True # Lower is worse
|
|
212
|
-
)
|
|
213
|
-
if alert:
|
|
214
|
-
alerts.append(alert)
|
|
215
|
-
|
|
216
|
-
# Dependency alerts
|
|
217
|
-
if "deps" in metrics:
|
|
218
|
-
deps = metrics["deps"]
|
|
219
|
-
max_depth = deps.get("max_depth", 0)
|
|
220
|
-
|
|
221
|
-
alert = self._check_threshold(
|
|
222
|
-
"deps.max_depth", max_depth,
|
|
223
|
-
f"Dependency chain depth of {max_depth}"
|
|
224
|
-
)
|
|
225
|
-
if alert:
|
|
226
|
-
alerts.append(alert)
|
|
227
|
-
|
|
228
|
-
circular = deps.get("circular", 0)
|
|
229
|
-
if circular > 0:
|
|
230
|
-
alerts.append(Alert(
|
|
231
|
-
level=AlertLevel.CRITICAL,
|
|
232
|
-
metric="deps.circular",
|
|
233
|
-
message=f"{circular} circular dependencies detected!",
|
|
234
|
-
value=circular,
|
|
235
|
-
threshold=0,
|
|
236
|
-
))
|
|
237
|
-
|
|
238
|
-
self.active_alerts = alerts
|
|
239
|
-
return alerts
|
|
240
|
-
|
|
241
|
-
def _check_threshold(
|
|
242
|
-
self, metric: str, value: float, message: str, inverse: bool = False
|
|
243
|
-
) -> Optional[Alert]:
|
|
244
|
-
"""Check single metric against thresholds."""
|
|
245
|
-
thresholds = self.thresholds.get(metric, {})
|
|
246
|
-
|
|
247
|
-
critical_threshold = thresholds.get("critical")
|
|
248
|
-
warning_threshold = thresholds.get("warning")
|
|
249
|
-
|
|
250
|
-
if inverse:
|
|
251
|
-
# Lower values are worse (e.g., budget remaining)
|
|
252
|
-
if critical_threshold is not None and value <= critical_threshold:
|
|
253
|
-
return Alert(AlertLevel.CRITICAL, metric, message, value, critical_threshold)
|
|
254
|
-
if warning_threshold is not None and value <= warning_threshold:
|
|
255
|
-
return Alert(AlertLevel.WARNING, metric, message, value, warning_threshold)
|
|
256
|
-
else:
|
|
257
|
-
# Higher values are worse (e.g., pending tasks)
|
|
258
|
-
if critical_threshold is not None and value >= critical_threshold:
|
|
259
|
-
return Alert(AlertLevel.CRITICAL, metric, message, value, critical_threshold)
|
|
260
|
-
if warning_threshold is not None and value >= warning_threshold:
|
|
261
|
-
return Alert(AlertLevel.WARNING, metric, message, value, warning_threshold)
|
|
262
|
-
|
|
263
|
-
return None
|
|
264
|
-
|
|
265
|
-
def get_active_alerts(self) -> List[Alert]:
|
|
266
|
-
"""Get currently active alerts."""
|
|
267
|
-
return self.active_alerts
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
class TrendAnalyzer:
|
|
271
|
-
"""Trend analyzer with JSONL persistence."""
|
|
272
|
-
|
|
273
|
-
def __init__(self, brain_path: Path = None, retention_days: int = 7):
|
|
274
|
-
self.brain_path = brain_path
|
|
275
|
-
self.retention_days = retention_days
|
|
276
|
-
self.metrics_file = None
|
|
277
|
-
if brain_path:
|
|
278
|
-
self.metrics_file = brain_path / "ledger" / "metrics.jsonl"
|
|
279
|
-
|
|
280
|
-
def record_metrics(self, metrics: Dict[str, Any], interval: str = "hourly") -> None:
|
|
281
|
-
"""Record metrics to JSONL file."""
|
|
282
|
-
if not self.metrics_file:
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
self.metrics_file.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
-
|
|
287
|
-
entry = {
|
|
288
|
-
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
289
|
-
"interval": interval,
|
|
290
|
-
"metrics": self._flatten_metrics(metrics),
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
with open(self.metrics_file, "a") as f:
|
|
294
|
-
f.write(json.dumps(entry) + "\n")
|
|
295
|
-
|
|
296
|
-
self._cleanup_old_entries()
|
|
297
|
-
|
|
298
|
-
def get_trends(self, metric: str, hours: int = 24) -> List[Dict]:
|
|
299
|
-
"""Get trend data for a specific metric."""
|
|
300
|
-
if not self.metrics_file or not self.metrics_file.exists():
|
|
301
|
-
return []
|
|
302
|
-
|
|
303
|
-
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
|
304
|
-
trends = []
|
|
305
|
-
|
|
306
|
-
with open(self.metrics_file) as f:
|
|
307
|
-
for line in f:
|
|
308
|
-
try:
|
|
309
|
-
entry = json.loads(line.strip())
|
|
310
|
-
ts = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00"))
|
|
311
|
-
if ts.replace(tzinfo=None) >= cutoff:
|
|
312
|
-
value = entry["metrics"].get(metric)
|
|
313
|
-
if value is not None:
|
|
314
|
-
trends.append({
|
|
315
|
-
"timestamp": entry["timestamp"],
|
|
316
|
-
"value": value,
|
|
317
|
-
})
|
|
318
|
-
except (json.JSONDecodeError, KeyError, ValueError):
|
|
319
|
-
continue
|
|
320
|
-
|
|
321
|
-
return trends
|
|
322
|
-
|
|
323
|
-
def get_velocity(self, hours: int = 24) -> float:
|
|
324
|
-
"""Calculate task completion velocity (tasks/hour)."""
|
|
325
|
-
trends = self.get_trends("tasks.done", hours)
|
|
326
|
-
if len(trends) < 2:
|
|
327
|
-
return 0.0
|
|
328
|
-
|
|
329
|
-
first = trends[0]["value"]
|
|
330
|
-
last = trends[-1]["value"]
|
|
331
|
-
delta = last - first
|
|
332
|
-
|
|
333
|
-
return delta / hours if hours > 0 else 0.0
|
|
334
|
-
|
|
335
|
-
def _flatten_metrics(self, metrics: Dict, prefix: str = "") -> Dict[str, Any]:
|
|
336
|
-
"""Flatten nested metrics dict to dot-notation keys."""
|
|
337
|
-
result = {}
|
|
338
|
-
for key, value in metrics.items():
|
|
339
|
-
full_key = f"{prefix}.{key}" if prefix else key
|
|
340
|
-
if isinstance(value, dict):
|
|
341
|
-
result.update(self._flatten_metrics(value, full_key))
|
|
342
|
-
else:
|
|
343
|
-
result[full_key] = value
|
|
344
|
-
return result
|
|
345
|
-
|
|
346
|
-
def _cleanup_old_entries(self) -> None:
|
|
347
|
-
"""Remove entries older than retention period."""
|
|
348
|
-
if not self.metrics_file or not self.metrics_file.exists():
|
|
349
|
-
return
|
|
350
|
-
|
|
351
|
-
cutoff = datetime.utcnow() - timedelta(days=self.retention_days)
|
|
352
|
-
kept_lines = []
|
|
353
|
-
|
|
354
|
-
with open(self.metrics_file) as f:
|
|
355
|
-
for line in f:
|
|
356
|
-
try:
|
|
357
|
-
entry = json.loads(line.strip())
|
|
358
|
-
ts = datetime.fromisoformat(entry["timestamp"].replace("Z", "+00:00"))
|
|
359
|
-
if ts.replace(tzinfo=None) >= cutoff:
|
|
360
|
-
kept_lines.append(line)
|
|
361
|
-
except (json.JSONDecodeError, KeyError, ValueError):
|
|
362
|
-
continue
|
|
363
|
-
|
|
364
|
-
with open(self.metrics_file, "w") as f:
|
|
365
|
-
f.writelines(kept_lines)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
class SnapshotManager:
|
|
369
|
-
"""Manage dashboard snapshots for comparison."""
|
|
370
|
-
|
|
371
|
-
def __init__(self, brain_path: Path = None, max_snapshots: int = 100):
|
|
372
|
-
self.brain_path = brain_path
|
|
373
|
-
self.max_snapshots = max_snapshots
|
|
374
|
-
self.snapshots_dir = None
|
|
375
|
-
if brain_path:
|
|
376
|
-
self.snapshots_dir = brain_path / "snapshots" / "dashboard"
|
|
377
|
-
|
|
378
|
-
def create(self, metrics: Dict, alerts: List[Alert], name: str = None) -> Snapshot:
|
|
379
|
-
"""Create a new snapshot."""
|
|
380
|
-
snapshot_id = f"snap_{int(time.time())}_{hashlib.sha256(str(time.time()).encode()).hexdigest()[:6]}"
|
|
381
|
-
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
382
|
-
|
|
383
|
-
snapshot = Snapshot(
|
|
384
|
-
id=snapshot_id,
|
|
385
|
-
name=name or f"Snapshot {timestamp}",
|
|
386
|
-
timestamp=timestamp,
|
|
387
|
-
metrics=metrics,
|
|
388
|
-
alerts=[a.to_dict() for a in alerts],
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
if self.snapshots_dir:
|
|
392
|
-
self.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
393
|
-
snapshot_file = self.snapshots_dir / f"{snapshot_id}.json"
|
|
394
|
-
with open(snapshot_file, "w") as f:
|
|
395
|
-
json.dump(snapshot.to_dict(), f, indent=2)
|
|
396
|
-
|
|
397
|
-
self._cleanup_old_snapshots()
|
|
398
|
-
|
|
399
|
-
return snapshot
|
|
400
|
-
|
|
401
|
-
def get(self, snapshot_id: str) -> Optional[Snapshot]:
|
|
402
|
-
"""Get a snapshot by ID."""
|
|
403
|
-
if not self.snapshots_dir:
|
|
404
|
-
return None
|
|
405
|
-
|
|
406
|
-
snapshot_file = self.snapshots_dir / f"{snapshot_id}.json"
|
|
407
|
-
if not snapshot_file.exists():
|
|
408
|
-
return None
|
|
409
|
-
|
|
410
|
-
with open(snapshot_file) as f:
|
|
411
|
-
data = json.load(f)
|
|
412
|
-
|
|
413
|
-
return Snapshot(**data)
|
|
414
|
-
|
|
415
|
-
def list(self, limit: int = 10) -> List[Dict]:
|
|
416
|
-
"""List recent snapshots."""
|
|
417
|
-
if not self.snapshots_dir or not self.snapshots_dir.exists():
|
|
418
|
-
return []
|
|
419
|
-
|
|
420
|
-
snapshots = []
|
|
421
|
-
for f in sorted(self.snapshots_dir.glob("snap_*.json"), reverse=True)[:limit]:
|
|
422
|
-
with open(f) as file:
|
|
423
|
-
data = json.load(file)
|
|
424
|
-
snapshots.append({
|
|
425
|
-
"id": data["id"],
|
|
426
|
-
"name": data["name"],
|
|
427
|
-
"timestamp": data["timestamp"],
|
|
428
|
-
})
|
|
429
|
-
|
|
430
|
-
return snapshots
|
|
431
|
-
|
|
432
|
-
def compare(self, snapshot_a: str, snapshot_b: str) -> Dict:
|
|
433
|
-
"""Compare two snapshots."""
|
|
434
|
-
a = self.get(snapshot_a)
|
|
435
|
-
b = self.get(snapshot_b)
|
|
436
|
-
|
|
437
|
-
if not a or not b:
|
|
438
|
-
return {"error": "Snapshot not found"}
|
|
439
|
-
|
|
440
|
-
comparison = {
|
|
441
|
-
"a": {"id": a.id, "timestamp": a.timestamp},
|
|
442
|
-
"b": {"id": b.id, "timestamp": b.timestamp},
|
|
443
|
-
"deltas": {},
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
# Compare flat metrics
|
|
447
|
-
a_flat = self._flatten(a.metrics)
|
|
448
|
-
b_flat = self._flatten(b.metrics)
|
|
449
|
-
|
|
450
|
-
all_keys = set(a_flat.keys()) | set(b_flat.keys())
|
|
451
|
-
for key in all_keys:
|
|
452
|
-
a_val = a_flat.get(key, 0)
|
|
453
|
-
b_val = b_flat.get(key, 0)
|
|
454
|
-
|
|
455
|
-
if isinstance(a_val, (int, float)) and isinstance(b_val, (int, float)):
|
|
456
|
-
delta = b_val - a_val
|
|
457
|
-
if delta != 0:
|
|
458
|
-
comparison["deltas"][key] = {
|
|
459
|
-
"a": a_val,
|
|
460
|
-
"b": b_val,
|
|
461
|
-
"delta": delta,
|
|
462
|
-
"direction": "up" if delta > 0 else "down",
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
return comparison
|
|
466
|
-
|
|
467
|
-
def _flatten(self, d: Dict, prefix: str = "") -> Dict:
|
|
468
|
-
"""Flatten nested dict."""
|
|
469
|
-
result = {}
|
|
470
|
-
for k, v in d.items():
|
|
471
|
-
key = f"{prefix}.{k}" if prefix else k
|
|
472
|
-
if isinstance(v, dict):
|
|
473
|
-
result.update(self._flatten(v, key))
|
|
474
|
-
else:
|
|
475
|
-
result[key] = v
|
|
476
|
-
return result
|
|
477
|
-
|
|
478
|
-
def _cleanup_old_snapshots(self) -> None:
|
|
479
|
-
"""Remove old snapshots beyond max limit."""
|
|
480
|
-
if not self.snapshots_dir:
|
|
481
|
-
return
|
|
482
|
-
|
|
483
|
-
snapshots = sorted(self.snapshots_dir.glob("snap_*.json"))
|
|
484
|
-
while len(snapshots) > self.max_snapshots:
|
|
485
|
-
oldest = snapshots.pop(0)
|
|
486
|
-
oldest.unlink()
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
class ASCIIFormatter:
|
|
490
|
-
"""Format dashboard as ASCII art for terminal display."""
|
|
491
|
-
|
|
492
|
-
LEVEL_ICONS = {
|
|
493
|
-
"minimal": "📊",
|
|
494
|
-
"standard": "🚀",
|
|
495
|
-
"verbose": "📈",
|
|
496
|
-
"full": "🔬",
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
def format(
|
|
500
|
-
self,
|
|
501
|
-
metrics: Dict[str, Any],
|
|
502
|
-
alerts: List[Alert],
|
|
503
|
-
detail_level: str = "standard",
|
|
504
|
-
) -> str:
|
|
505
|
-
"""Format metrics as ASCII dashboard."""
|
|
506
|
-
lines = []
|
|
507
|
-
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
508
|
-
icon = self.LEVEL_ICONS.get(detail_level, "📊")
|
|
509
|
-
|
|
510
|
-
lines.append(f"{icon} NOP Status Dashboard - {now}")
|
|
511
|
-
lines.append("═" * 60)
|
|
512
|
-
|
|
513
|
-
# Alerts section (always shown if any)
|
|
514
|
-
if alerts:
|
|
515
|
-
lines.append("")
|
|
516
|
-
lines.append("⚠️ ALERTS")
|
|
517
|
-
lines.append("─" * 40)
|
|
518
|
-
for alert in alerts:
|
|
519
|
-
icon = "🔴" if alert.level == AlertLevel.CRITICAL else "🟡"
|
|
520
|
-
lines.append(f" {icon} [{alert.level.value.upper()}] {alert.message}")
|
|
521
|
-
lines.append("")
|
|
522
|
-
|
|
523
|
-
# Agent Pool Health
|
|
524
|
-
if "agents" in metrics:
|
|
525
|
-
lines.extend(self._format_agents(metrics["agents"], detail_level))
|
|
526
|
-
|
|
527
|
-
# Task Queue
|
|
528
|
-
if "tasks" in metrics:
|
|
529
|
-
lines.extend(self._format_tasks(metrics["tasks"], detail_level))
|
|
530
|
-
|
|
531
|
-
# Ingestion (verbose/full only)
|
|
532
|
-
if "ingestion" in metrics and detail_level in ["verbose", "full"]:
|
|
533
|
-
lines.extend(self._format_ingestion(metrics["ingestion"]))
|
|
534
|
-
|
|
535
|
-
# Cost Tracking
|
|
536
|
-
if "cost" in metrics:
|
|
537
|
-
lines.extend(self._format_cost(metrics["cost"], detail_level))
|
|
538
|
-
|
|
539
|
-
# Dependencies (verbose/full only)
|
|
540
|
-
if "deps" in metrics and detail_level in ["verbose", "full"]:
|
|
541
|
-
lines.extend(self._format_deps(metrics["deps"]))
|
|
542
|
-
|
|
543
|
-
# System Health (full only)
|
|
544
|
-
if "system" in metrics and detail_level == "full":
|
|
545
|
-
lines.extend(self._format_system(metrics["system"]))
|
|
546
|
-
|
|
547
|
-
return "\n".join(lines)
|
|
548
|
-
|
|
549
|
-
def _format_agents(self, agents: Dict, detail_level: str) -> List[str]:
|
|
550
|
-
"""Format agent pool section."""
|
|
551
|
-
lines = ["", "📊 AGENT POOL HEALTH"]
|
|
552
|
-
lines.append("─" * 40)
|
|
553
|
-
|
|
554
|
-
total = agents.get("total", 0)
|
|
555
|
-
active = agents.get("active", 0)
|
|
556
|
-
idle = agents.get("idle", 0)
|
|
557
|
-
exhausted = agents.get("exhausted", 0)
|
|
558
|
-
utilization = agents.get("utilization", 0)
|
|
559
|
-
|
|
560
|
-
pct = f"({int(utilization*100)}%)" if total > 0 else ""
|
|
561
|
-
lines.append(f" ├── Active: {active}/{total} {pct}")
|
|
562
|
-
lines.append(f" ├── Idle: {idle}")
|
|
563
|
-
lines.append(f" ├── Exhausted: {exhausted}")
|
|
564
|
-
|
|
565
|
-
if detail_level in ["verbose", "full"]:
|
|
566
|
-
reset_warnings = agents.get("reset_warnings", [])
|
|
567
|
-
if reset_warnings:
|
|
568
|
-
lines.append(f" └── Reset Warnings: {len(reset_warnings)}")
|
|
569
|
-
for w in reset_warnings[:3]:
|
|
570
|
-
lines.append(f" └── {w.get('slot_id', 'unknown')} in {w.get('minutes', '?')}m")
|
|
571
|
-
else:
|
|
572
|
-
lines.append(" └── Reset Warnings: 0")
|
|
573
|
-
else:
|
|
574
|
-
lines.append(f" └── Capacity: {int(utilization*100)}%")
|
|
575
|
-
|
|
576
|
-
return lines
|
|
577
|
-
|
|
578
|
-
def _format_tasks(self, tasks: Dict, detail_level: str) -> List[str]:
|
|
579
|
-
"""Format task queue section."""
|
|
580
|
-
lines = ["", "📋 TASK QUEUE"]
|
|
581
|
-
lines.append("─" * 40)
|
|
582
|
-
|
|
583
|
-
pending = tasks.get("pending", 0)
|
|
584
|
-
in_progress = tasks.get("in_progress", 0)
|
|
585
|
-
blocked = tasks.get("blocked", 0)
|
|
586
|
-
done = tasks.get("done", 0)
|
|
587
|
-
failed = tasks.get("failed", 0)
|
|
588
|
-
velocity = tasks.get("velocity", 0)
|
|
589
|
-
|
|
590
|
-
lines.append(f" ├── Pending: {pending}")
|
|
591
|
-
lines.append(f" ├── In Progress: {in_progress}")
|
|
592
|
-
lines.append(f" ├── Blocked: {blocked}")
|
|
593
|
-
|
|
594
|
-
if detail_level in ["verbose", "full"]:
|
|
595
|
-
lines.append(f" ├── Done (24h): {done}")
|
|
596
|
-
lines.append(f" ├── Failed: {failed}")
|
|
597
|
-
lines.append(f" └── Velocity: {velocity:.1f}/hr")
|
|
598
|
-
else:
|
|
599
|
-
lines.append(f" └── Done (24h): {done}")
|
|
600
|
-
|
|
601
|
-
return lines
|
|
602
|
-
|
|
603
|
-
def _format_ingestion(self, ingestion: Dict) -> List[str]:
|
|
604
|
-
"""Format ingestion section."""
|
|
605
|
-
lines = ["", "📥 INGESTION"]
|
|
606
|
-
lines.append("─" * 40)
|
|
607
|
-
|
|
608
|
-
total = ingestion.get("total", 0)
|
|
609
|
-
skipped = ingestion.get("skipped", 0)
|
|
610
|
-
failed = ingestion.get("failed", 0)
|
|
611
|
-
batches = ingestion.get("batches", 0)
|
|
612
|
-
|
|
613
|
-
lines.append(f" ├── Total Ingested: {total}")
|
|
614
|
-
lines.append(f" ├── Skipped (Dupes): {skipped}")
|
|
615
|
-
lines.append(f" ├── Failed: {failed}")
|
|
616
|
-
lines.append(f" └── Batches: {batches}")
|
|
617
|
-
|
|
618
|
-
return lines
|
|
619
|
-
|
|
620
|
-
def _format_cost(self, cost: Dict, detail_level: str) -> List[str]:
|
|
621
|
-
"""Format cost tracking section."""
|
|
622
|
-
lines = ["", "💰 COST TRACKING"]
|
|
623
|
-
lines.append("─" * 40)
|
|
624
|
-
|
|
625
|
-
tokens = cost.get("tokens", 0)
|
|
626
|
-
usd = cost.get("usd", 0)
|
|
627
|
-
budget = cost.get("budget", 0)
|
|
628
|
-
remaining = cost.get("remaining", 0)
|
|
629
|
-
burn_rate = cost.get("burn_rate", 0)
|
|
630
|
-
|
|
631
|
-
# Format tokens
|
|
632
|
-
if tokens >= 1_000_000:
|
|
633
|
-
tokens_str = f"{tokens/1_000_000:.1f}M"
|
|
634
|
-
elif tokens >= 1_000:
|
|
635
|
-
tokens_str = f"{tokens/1_000:.1f}K"
|
|
636
|
-
else:
|
|
637
|
-
tokens_str = str(tokens)
|
|
638
|
-
|
|
639
|
-
lines.append(f" ├── Tokens: {tokens_str}")
|
|
640
|
-
lines.append(f" ├── Estimated: ${usd:.2f}")
|
|
641
|
-
|
|
642
|
-
if budget > 0:
|
|
643
|
-
pct = int((remaining / budget) * 100)
|
|
644
|
-
lines.append(f" ├── Budget: ${budget:.2f} ({pct}% remaining)")
|
|
645
|
-
|
|
646
|
-
if detail_level in ["verbose", "full"]:
|
|
647
|
-
lines.append(f" └── Burn Rate: ${burn_rate:.2f}/hr")
|
|
648
|
-
else:
|
|
649
|
-
lines.append(f" └── Remaining: ${remaining:.2f}")
|
|
650
|
-
|
|
651
|
-
return lines
|
|
652
|
-
|
|
653
|
-
def _format_deps(self, deps: Dict) -> List[str]:
|
|
654
|
-
"""Format dependency section."""
|
|
655
|
-
lines = ["", "🔗 DEPENDENCIES"]
|
|
656
|
-
lines.append("─" * 40)
|
|
657
|
-
|
|
658
|
-
max_depth = deps.get("max_depth", 0)
|
|
659
|
-
blocked_chains = deps.get("blocked_chains", 0)
|
|
660
|
-
circular = deps.get("circular", 0)
|
|
661
|
-
|
|
662
|
-
lines.append(f" ├── Max Depth: {max_depth}")
|
|
663
|
-
lines.append(f" ├── Blocked Chains: {blocked_chains}")
|
|
664
|
-
lines.append(f" └── Circular: {circular}")
|
|
665
|
-
|
|
666
|
-
return lines
|
|
667
|
-
|
|
668
|
-
def _format_system(self, system: Dict) -> List[str]:
|
|
669
|
-
"""Format system health section."""
|
|
670
|
-
lines = ["", "🖥️ SYSTEM HEALTH"]
|
|
671
|
-
lines.append("─" * 40)
|
|
672
|
-
|
|
673
|
-
uptime = system.get("uptime", "N/A")
|
|
674
|
-
last_activity = system.get("last_activity", "N/A")
|
|
675
|
-
error_rate = system.get("error_rate", 0)
|
|
676
|
-
|
|
677
|
-
lines.append(f" ├── Uptime: {uptime}")
|
|
678
|
-
lines.append(f" ├── Last Activity: {last_activity}")
|
|
679
|
-
lines.append(f" └── Error Rate: {error_rate}/hr")
|
|
680
|
-
|
|
681
|
-
return lines
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
class JSONFormatter:
|
|
685
|
-
"""Format dashboard as JSON for API consumption."""
|
|
686
|
-
|
|
687
|
-
def format(
|
|
688
|
-
self,
|
|
689
|
-
metrics: Dict[str, Any],
|
|
690
|
-
alerts: List[Alert],
|
|
691
|
-
detail_level: str = "standard",
|
|
692
|
-
) -> str:
|
|
693
|
-
"""Format metrics as JSON."""
|
|
694
|
-
output = {
|
|
695
|
-
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
696
|
-
"detail_level": detail_level,
|
|
697
|
-
"metrics": metrics,
|
|
698
|
-
"alerts": [a.to_dict() for a in alerts],
|
|
699
|
-
}
|
|
700
|
-
return json.dumps(output, indent=2)
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
class MermaidFormatter:
|
|
704
|
-
"""Format dependency graph as Mermaid diagram."""
|
|
705
|
-
|
|
706
|
-
def format(self, deps: Dict) -> str:
|
|
707
|
-
"""Format dependencies as Mermaid diagram."""
|
|
708
|
-
lines = ["```mermaid", "graph TD"]
|
|
709
|
-
|
|
710
|
-
forward_deps = deps.get("forward_deps", {})
|
|
711
|
-
depths = deps.get("depths", {})
|
|
712
|
-
|
|
713
|
-
# Add nodes with depth-based styling
|
|
714
|
-
nodes_added = set()
|
|
715
|
-
for task_id, task_deps in forward_deps.items():
|
|
716
|
-
if task_id not in nodes_added:
|
|
717
|
-
depth = depths.get(task_id, 0)
|
|
718
|
-
style = self._get_node_style(depth)
|
|
719
|
-
lines.append(f" {self._safe_id(task_id)}{style}")
|
|
720
|
-
nodes_added.add(task_id)
|
|
721
|
-
|
|
722
|
-
for dep_id in task_deps:
|
|
723
|
-
if dep_id not in nodes_added:
|
|
724
|
-
depth = depths.get(dep_id, 0)
|
|
725
|
-
style = self._get_node_style(depth)
|
|
726
|
-
lines.append(f" {self._safe_id(dep_id)}{style}")
|
|
727
|
-
nodes_added.add(dep_id)
|
|
728
|
-
|
|
729
|
-
lines.append(f" {self._safe_id(dep_id)} --> {self._safe_id(task_id)}")
|
|
730
|
-
|
|
731
|
-
lines.append("```")
|
|
732
|
-
return "\n".join(lines)
|
|
733
|
-
|
|
734
|
-
def _safe_id(self, task_id: str) -> str:
|
|
735
|
-
"""Convert task ID to safe Mermaid node ID."""
|
|
736
|
-
return task_id.replace("-", "_").replace(".", "_")
|
|
737
|
-
|
|
738
|
-
def _get_node_style(self, depth: int) -> str:
|
|
739
|
-
"""Get node style based on depth."""
|
|
740
|
-
if depth == 0:
|
|
741
|
-
return "[🟢 Root]"
|
|
742
|
-
elif depth <= 2:
|
|
743
|
-
return f"[Depth {depth}]"
|
|
744
|
-
elif depth <= 4:
|
|
745
|
-
return f"[⚠️ Depth {depth}]"
|
|
746
|
-
else:
|
|
747
|
-
return f"[🔴 Depth {depth}]"
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
class MetricsCollector:
|
|
751
|
-
"""Collect metrics from all NOP V3.1 components."""
|
|
752
|
-
|
|
753
|
-
def __init__(self, orchestrator=None, brain_path: Path = None):
|
|
754
|
-
self.orch = orchestrator
|
|
755
|
-
self.brain_path = brain_path
|
|
756
|
-
self.cache = MetricsCache(ttl_ms=100)
|
|
757
|
-
|
|
758
|
-
def collect_all(self, use_cache: bool = True) -> Dict[str, Any]:
|
|
759
|
-
"""Collect all metrics from all components."""
|
|
760
|
-
if use_cache:
|
|
761
|
-
cached = self.cache.get("all_metrics")
|
|
762
|
-
if cached:
|
|
763
|
-
return cached
|
|
764
|
-
|
|
765
|
-
metrics = {
|
|
766
|
-
"agents": self.collect_agent_metrics(),
|
|
767
|
-
"tasks": self.collect_task_metrics(),
|
|
768
|
-
"ingestion": self.collect_ingestion_metrics(),
|
|
769
|
-
"cost": self.collect_cost_metrics(),
|
|
770
|
-
"deps": self.collect_dependency_metrics(),
|
|
771
|
-
"system": self.collect_system_metrics(),
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
if use_cache:
|
|
775
|
-
self.cache.set("all_metrics", metrics)
|
|
776
|
-
|
|
777
|
-
return metrics
|
|
778
|
-
|
|
779
|
-
def collect_agent_metrics(self) -> Dict:
|
|
780
|
-
"""Collect agent pool metrics."""
|
|
781
|
-
try:
|
|
782
|
-
if self.orch:
|
|
783
|
-
pool = self.orch.get_agent_pool()
|
|
784
|
-
if pool:
|
|
785
|
-
pool_metrics = pool.get_pool_metrics() if hasattr(pool, 'get_pool_metrics') else {}
|
|
786
|
-
return {
|
|
787
|
-
"total": pool_metrics.get("total_agents", 0),
|
|
788
|
-
"active": pool_metrics.get("active_agents", 0),
|
|
789
|
-
"idle": pool_metrics.get("idle_agents", 0),
|
|
790
|
-
"exhausted": pool_metrics.get("exhausted_agents", 0),
|
|
791
|
-
"utilization": pool_metrics.get("utilization", 0),
|
|
792
|
-
"reset_warnings": [],
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
# Fallback: read from registry
|
|
796
|
-
if self.brain_path:
|
|
797
|
-
registry_path = self.brain_path / "slots" / "registry.json"
|
|
798
|
-
if registry_path.exists():
|
|
799
|
-
with open(registry_path) as f:
|
|
800
|
-
registry = json.load(f)
|
|
801
|
-
|
|
802
|
-
slots = registry.get("slots", {})
|
|
803
|
-
total = len(slots)
|
|
804
|
-
active = sum(1 for s in slots.values() if s.get("status") == "active")
|
|
805
|
-
exhausted = sum(1 for s in slots.values() if s.get("status") == "exhausted")
|
|
806
|
-
idle = active # Approximate
|
|
807
|
-
|
|
808
|
-
return {
|
|
809
|
-
"total": total,
|
|
810
|
-
"active": active,
|
|
811
|
-
"idle": idle,
|
|
812
|
-
"exhausted": exhausted,
|
|
813
|
-
"utilization": active / total if total > 0 else 0,
|
|
814
|
-
"reset_warnings": [],
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
return {"error": "Agent metrics unavailable"}
|
|
818
|
-
except Exception as e:
|
|
819
|
-
return {"error": str(e)}
|
|
820
|
-
|
|
821
|
-
def collect_task_metrics(self) -> Dict:
|
|
822
|
-
"""Collect task queue metrics."""
|
|
823
|
-
try:
|
|
824
|
-
if self.orch:
|
|
825
|
-
pool_metrics = self.orch.get_pool_metrics()
|
|
826
|
-
return {
|
|
827
|
-
"total": pool_metrics.get("total_tasks", 0),
|
|
828
|
-
"pending": pool_metrics.get("pending", 0),
|
|
829
|
-
"in_progress": pool_metrics.get("in_progress", 0),
|
|
830
|
-
"blocked": pool_metrics.get("blocked", 0),
|
|
831
|
-
"done": pool_metrics.get("done", 0),
|
|
832
|
-
"failed": pool_metrics.get("failed", 0),
|
|
833
|
-
"velocity": 0, # Calculated from trends
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
# Fallback: read from tasks.json
|
|
837
|
-
if self.brain_path:
|
|
838
|
-
tasks_path = self.brain_path / "ledger" / "tasks.json"
|
|
839
|
-
if tasks_path.exists():
|
|
840
|
-
with open(tasks_path) as f:
|
|
841
|
-
data = json.load(f)
|
|
842
|
-
|
|
843
|
-
tasks = data.get("tasks", [])
|
|
844
|
-
return {
|
|
845
|
-
"total": len(tasks),
|
|
846
|
-
"pending": sum(1 for t in tasks if t.get("status") in ["PENDING", "READY"]),
|
|
847
|
-
"in_progress": sum(1 for t in tasks if t.get("status") == "IN_PROGRESS"),
|
|
848
|
-
"blocked": sum(1 for t in tasks if t.get("status") == "BLOCKED"),
|
|
849
|
-
"done": sum(1 for t in tasks if t.get("status") == "DONE"),
|
|
850
|
-
"failed": sum(1 for t in tasks if t.get("status") == "FAILED"),
|
|
851
|
-
"velocity": 0,
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
return {"error": "Task metrics unavailable"}
|
|
855
|
-
except Exception as e:
|
|
856
|
-
return {"error": str(e)}
|
|
857
|
-
|
|
858
|
-
def collect_ingestion_metrics(self) -> Dict:
|
|
859
|
-
"""Collect ingestion statistics."""
|
|
860
|
-
try:
|
|
861
|
-
if self.orch:
|
|
862
|
-
stats = self.orch.get_ingestion_stats()
|
|
863
|
-
return {
|
|
864
|
-
"total": stats.get("total_ingested", 0),
|
|
865
|
-
"skipped": stats.get("total_skipped", 0),
|
|
866
|
-
"failed": stats.get("total_failed", 0),
|
|
867
|
-
"batches": stats.get("batches_count", 0),
|
|
868
|
-
"by_source": stats.get("by_source", {}),
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
return {
|
|
872
|
-
"total": 0,
|
|
873
|
-
"skipped": 0,
|
|
874
|
-
"failed": 0,
|
|
875
|
-
"batches": 0,
|
|
876
|
-
"by_source": {},
|
|
877
|
-
}
|
|
878
|
-
except Exception as e:
|
|
879
|
-
return {"error": str(e)}
|
|
880
|
-
|
|
881
|
-
def collect_cost_metrics(self) -> Dict:
|
|
882
|
-
"""Collect cost tracking metrics."""
|
|
883
|
-
# This would integrate with LLM client cost tracking
|
|
884
|
-
return {
|
|
885
|
-
"tokens": 0,
|
|
886
|
-
"usd": 0.0,
|
|
887
|
-
"budget": 10.0,
|
|
888
|
-
"remaining": 10.0,
|
|
889
|
-
"burn_rate": 0.0,
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
def collect_dependency_metrics(self) -> Dict:
|
|
893
|
-
"""Collect dependency graph metrics."""
|
|
894
|
-
try:
|
|
895
|
-
if self.orch:
|
|
896
|
-
graph = self.orch.get_dependency_graph()
|
|
897
|
-
depths = graph.get("depths", {})
|
|
898
|
-
|
|
899
|
-
max_depth = max(depths.values()) if depths else 0
|
|
900
|
-
circular = sum(1 for d in depths.values() if d < 0)
|
|
901
|
-
blocked_chains = sum(1 for d in depths.values() if d > 3)
|
|
902
|
-
|
|
903
|
-
return {
|
|
904
|
-
"max_depth": max_depth,
|
|
905
|
-
"blocked_chains": blocked_chains,
|
|
906
|
-
"circular": circular,
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
return {"max_depth": 0, "blocked_chains": 0, "circular": 0}
|
|
910
|
-
except Exception as e:
|
|
911
|
-
return {"error": str(e)}
|
|
912
|
-
|
|
913
|
-
def collect_system_metrics(self) -> Dict:
|
|
914
|
-
"""Collect system health metrics."""
|
|
915
|
-
return {
|
|
916
|
-
"uptime": "N/A",
|
|
917
|
-
"last_activity": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
918
|
-
"error_rate": 0,
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
class DashboardEngine:
|
|
923
|
-
"""
|
|
924
|
-
Enterprise-grade orchestration dashboard.
|
|
925
|
-
|
|
926
|
-
Provides real-time visibility into all NOP V3.1 components
|
|
927
|
-
with multiple output formats, alerting, and trend analysis.
|
|
928
|
-
"""
|
|
929
|
-
|
|
930
|
-
def __init__(
|
|
931
|
-
self,
|
|
932
|
-
orchestrator=None,
|
|
933
|
-
brain_path: Path = None,
|
|
934
|
-
):
|
|
935
|
-
self.orch = orchestrator
|
|
936
|
-
self.brain_path = brain_path
|
|
937
|
-
|
|
938
|
-
# Initialize components
|
|
939
|
-
self.collector = MetricsCollector(orchestrator, brain_path)
|
|
940
|
-
self.alert_engine = AlertEngine()
|
|
941
|
-
self.trend_analyzer = TrendAnalyzer(brain_path)
|
|
942
|
-
self.snapshot_manager = SnapshotManager(brain_path)
|
|
943
|
-
|
|
944
|
-
# Initialize formatters
|
|
945
|
-
self.formatters = {
|
|
946
|
-
OutputFormat.ASCII: ASCIIFormatter(),
|
|
947
|
-
OutputFormat.JSON: JSONFormatter(),
|
|
948
|
-
OutputFormat.MERMAID: MermaidFormatter(),
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
def render(
|
|
952
|
-
self,
|
|
953
|
-
detail_level: str = "standard",
|
|
954
|
-
format: str = "ascii",
|
|
955
|
-
include_alerts: bool = True,
|
|
956
|
-
include_trends: bool = False,
|
|
957
|
-
compare_to: str = None,
|
|
958
|
-
category: str = None,
|
|
959
|
-
) -> str:
|
|
960
|
-
"""
|
|
961
|
-
Render the dashboard in the specified format.
|
|
962
|
-
|
|
963
|
-
Args:
|
|
964
|
-
detail_level: "minimal", "standard", "verbose", "full"
|
|
965
|
-
format: "ascii", "json", "mermaid"
|
|
966
|
-
include_alerts: Include alert section
|
|
967
|
-
include_trends: Include trend data
|
|
968
|
-
compare_to: Snapshot ID or time offset ("1h", "24h")
|
|
969
|
-
category: Filter to specific category
|
|
970
|
-
|
|
971
|
-
Returns:
|
|
972
|
-
Formatted dashboard string
|
|
973
|
-
"""
|
|
974
|
-
# Collect metrics
|
|
975
|
-
metrics = self.collector.collect_all()
|
|
976
|
-
|
|
977
|
-
# Filter by category if specified
|
|
978
|
-
if category and category in metrics:
|
|
979
|
-
metrics = {category: metrics[category]}
|
|
980
|
-
|
|
981
|
-
# Add trends if requested
|
|
982
|
-
if include_trends:
|
|
983
|
-
velocity = self.trend_analyzer.get_velocity(hours=24)
|
|
984
|
-
if "tasks" in metrics:
|
|
985
|
-
metrics["tasks"]["velocity"] = velocity
|
|
986
|
-
|
|
987
|
-
# Check alerts
|
|
988
|
-
alerts = self.alert_engine.check(metrics) if include_alerts else []
|
|
989
|
-
|
|
990
|
-
# Handle comparison
|
|
991
|
-
if compare_to:
|
|
992
|
-
return self._render_comparison(metrics, compare_to)
|
|
993
|
-
|
|
994
|
-
# Format output
|
|
995
|
-
output_format = OutputFormat(format.lower())
|
|
996
|
-
|
|
997
|
-
if output_format == OutputFormat.MERMAID:
|
|
998
|
-
deps = metrics.get("deps", {})
|
|
999
|
-
if self.orch:
|
|
1000
|
-
deps = self.orch.get_dependency_graph()
|
|
1001
|
-
return self.formatters[output_format].format(deps)
|
|
1002
|
-
|
|
1003
|
-
formatter = self.formatters.get(output_format, self.formatters[OutputFormat.ASCII])
|
|
1004
|
-
return formatter.format(metrics, alerts, detail_level)
|
|
1005
|
-
|
|
1006
|
-
def _render_comparison(self, current_metrics: Dict, compare_to: str) -> str:
|
|
1007
|
-
"""Render comparison view."""
|
|
1008
|
-
# Parse compare_to
|
|
1009
|
-
if compare_to.endswith("h"):
|
|
1010
|
-
# Time offset comparison
|
|
1011
|
-
hours = int(compare_to[:-1])
|
|
1012
|
-
# For now, just show current - would need historical snapshots
|
|
1013
|
-
lines = [
|
|
1014
|
-
f"📊 Dashboard Comparison: Now vs {hours}h ago",
|
|
1015
|
-
"═" * 50,
|
|
1016
|
-
"(Historical comparison requires snapshot data)",
|
|
1017
|
-
]
|
|
1018
|
-
return "\n".join(lines)
|
|
1019
|
-
else:
|
|
1020
|
-
# Snapshot comparison
|
|
1021
|
-
comparison = self.snapshot_manager.compare("current", compare_to)
|
|
1022
|
-
if "error" in comparison:
|
|
1023
|
-
return f"❌ {comparison['error']}"
|
|
1024
|
-
|
|
1025
|
-
lines = [
|
|
1026
|
-
"📊 Dashboard Comparison",
|
|
1027
|
-
"═" * 50,
|
|
1028
|
-
]
|
|
1029
|
-
for key, delta in comparison.get("deltas", {}).items():
|
|
1030
|
-
direction = "✅" if delta["direction"] == "up" else "⬇️"
|
|
1031
|
-
lines.append(f" {key}: {delta['a']} → {delta['b']} ({direction} {abs(delta['delta'])})")
|
|
1032
|
-
|
|
1033
|
-
return "\n".join(lines)
|
|
1034
|
-
|
|
1035
|
-
def get_metrics(self, category: str = None) -> Dict:
|
|
1036
|
-
"""Get raw metrics dictionary."""
|
|
1037
|
-
metrics = self.collector.collect_all()
|
|
1038
|
-
if category and category in metrics:
|
|
1039
|
-
return {category: metrics[category]}
|
|
1040
|
-
return metrics
|
|
1041
|
-
|
|
1042
|
-
def get_alerts(self) -> List[Alert]:
|
|
1043
|
-
"""Get current active alerts."""
|
|
1044
|
-
metrics = self.collector.collect_all()
|
|
1045
|
-
return self.alert_engine.check(metrics)
|
|
1046
|
-
|
|
1047
|
-
def create_snapshot(self, name: str = None) -> Snapshot:
|
|
1048
|
-
"""Create a manual snapshot."""
|
|
1049
|
-
metrics = self.collector.collect_all(use_cache=False)
|
|
1050
|
-
alerts = self.alert_engine.check(metrics)
|
|
1051
|
-
return self.snapshot_manager.create(metrics, alerts, name)
|
|
1052
|
-
|
|
1053
|
-
def compare_snapshots(self, snapshot_a: str, snapshot_b: str) -> Dict:
|
|
1054
|
-
"""Compare two snapshots."""
|
|
1055
|
-
return self.snapshot_manager.compare(snapshot_a, snapshot_b)
|
|
1056
|
-
|
|
1057
|
-
def list_snapshots(self, limit: int = 10) -> List[Dict]:
|
|
1058
|
-
"""List available snapshots."""
|
|
1059
|
-
return self.snapshot_manager.list(limit)
|
|
1060
|
-
|
|
1061
|
-
def set_alert_threshold(self, metric: str, level: str, value: float) -> None:
|
|
1062
|
-
"""Set custom alert threshold."""
|
|
1063
|
-
self.alert_engine.set_threshold(metric, level, value)
|
|
1064
|
-
|
|
1065
|
-
def record_hourly_metrics(self) -> None:
|
|
1066
|
-
"""Record current metrics for trend analysis."""
|
|
1067
|
-
metrics = self.collector.collect_all(use_cache=False)
|
|
1068
|
-
self.trend_analyzer.record_metrics(metrics, interval="hourly")
|
|
1069
|
-
|
|
1070
|
-
def get_trends(self, metric: str, hours: int = 24) -> List[Dict]:
|
|
1071
|
-
"""Get trend data for a metric."""
|
|
1072
|
-
return self.trend_analyzer.get_trends(metric, hours)
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
def format_dashboard(
|
|
1076
|
-
metrics: Dict,
|
|
1077
|
-
alerts: List[Alert],
|
|
1078
|
-
detail_level: str = "standard",
|
|
1079
|
-
format: str = "ascii",
|
|
1080
|
-
) -> str:
|
|
1081
|
-
"""Standalone function for formatting dashboard output."""
|
|
1082
|
-
if format.lower() == "ascii":
|
|
1083
|
-
return ASCIIFormatter().format(metrics, alerts, detail_level)
|
|
1084
|
-
elif format.lower() == "json":
|
|
1085
|
-
return JSONFormatter().format(metrics, alerts, detail_level)
|
|
1086
|
-
else:
|
|
1087
|
-
return ASCIIFormatter().format(metrics, alerts, detail_level)
|