nat-engine 1__py3-none-any.whl

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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3567 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Dashboard routes and WebSocket endpoint for the NAT web dashboard.
7
+
8
+ Provides:
9
+ - ``GET /dashboard`` — serve the single-page dashboard HTML
10
+ - ``GET /api/v1/dashboard/summary`` — aggregated stats for the overview bar
11
+ - ``GET /api/v1/dashboard/settings`` — read-only server configuration
12
+ - ``GET /api/v1/scan/{scan_id}/export`` — export scan results (html|json|csv|pdf)
13
+ - ``WebSocket /ws/live`` — real-time updates during active scans
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import csv
20
+ import io
21
+ import json
22
+ import logging
23
+ import os
24
+ import pathlib
25
+ import uuid
26
+ from datetime import datetime, timedelta, timezone
27
+ from typing import Any, Dict, List, Optional, Set
28
+
29
+ from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect
30
+ from fastapi.responses import HTMLResponse, JSONResponse, Response
31
+
32
+ from mannf.product.dashboard.models import (
33
+ AnomalyTimelineEntry,
34
+ AutonomousRequest,
35
+ BeliefStateSnapshot,
36
+ BeliefTrend,
37
+ BugFilingConfig,
38
+ CalibrationReport,
39
+ CalibrationBucket,
40
+ DashboardSummary,
41
+ DismissedFinding,
42
+ DiscoveryRequest,
43
+ DiscoveryResult,
44
+ DiscoveryScenario,
45
+ EndpointRiskCard,
46
+ FailureCluster,
47
+ FiledBugEntry,
48
+ FlakinessSummary,
49
+ FunctionalTestResult,
50
+ OnboardingStatus,
51
+ OnboardingStep,
52
+ PendingBugEntry,
53
+ PredictiveRiskScore,
54
+ RootCauseSuggestion,
55
+ UsageSummary,
56
+ )
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ _STATIC_DIR = pathlib.Path(__file__).parent / "static"
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # In-memory store for discovery results (Phase 3)
64
+ # ---------------------------------------------------------------------------
65
+
66
+ _discoveries: Dict[str, DiscoveryResult] = {}
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # In-memory store for autonomous run reports (Phase 5)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ _autonomous_runs: Dict[str, Any] = {}
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # In-memory stores for auto-filed and pending bug tickets (Phase 6.4)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ _filed_bugs: Dict[str, FiledBugEntry] = {}
79
+ _pending_bugs: Dict[str, PendingBugEntry] = {}
80
+ _bug_filing_config: BugFilingConfig = BugFilingConfig()
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # In-memory store for dismissed findings / FP feedback (Phase 7.4)
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _dismissed_findings: Dict[str, DismissedFinding] = {}
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # In-memory stores for finding comments and scan share links (Phase 10.4)
90
+ # ---------------------------------------------------------------------------
91
+
92
+ # finding_id → list of comment dicts: {id, author, text, created_at}
93
+ _finding_comments: Dict[str, List[Dict[str, Any]]] = {}
94
+
95
+ # scan_id → share record dict: {token, scan_id, expires_at, created_at}
96
+ _scan_shares: Dict[str, Dict[str, Any]] = {}
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # In-memory flake detector singleton (Phase 6.5)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ from mannf.core.diagnostics.flake_detector import FlakeDetector as _FlakeDetector # noqa: E402
103
+
104
+ _flake_detector: _FlakeDetector = _FlakeDetector()
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # WebSocket connection manager for live updates
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ class _ConnectionManager:
112
+ """Manages active WebSocket connections and broadcasts messages."""
113
+
114
+ def __init__(self) -> None:
115
+ self._active: Set[WebSocket] = set()
116
+
117
+ async def connect(self, ws: WebSocket) -> None:
118
+ await ws.accept()
119
+ self._active.add(ws)
120
+ logger.debug("WebSocket client connected (%d total)", len(self._active))
121
+
122
+ def disconnect(self, ws: WebSocket) -> None:
123
+ self._active.discard(ws)
124
+ logger.debug("WebSocket client disconnected (%d total)", len(self._active))
125
+
126
+ async def broadcast(self, message: Dict[str, Any]) -> None:
127
+ """Send a JSON message to all connected clients."""
128
+ if not self._active:
129
+ return
130
+ text = json.dumps(message)
131
+ dead: Set[WebSocket] = set()
132
+ for ws in list(self._active):
133
+ try:
134
+ await ws.send_text(text)
135
+ except Exception: # noqa: BLE001
136
+ dead.add(ws)
137
+ self._active -= dead
138
+
139
+ @property
140
+ def connection_count(self) -> int:
141
+ return len(self._active)
142
+
143
+
144
+ manager = _ConnectionManager()
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Router
148
+ # ---------------------------------------------------------------------------
149
+
150
+ router = APIRouter(tags=["dashboard"])
151
+
152
+
153
+ @router.get("/dashboard", response_class=HTMLResponse)
154
+ async def dashboard_index() -> HTMLResponse:
155
+ """Serve the single-page dashboard HTML."""
156
+ html_path = _STATIC_DIR / "index.html"
157
+ content = html_path.read_text(encoding="utf-8")
158
+ return HTMLResponse(content=content)
159
+
160
+
161
+ @router.get("/dashboard/status", response_class=HTMLResponse)
162
+ async def dashboard_status() -> HTMLResponse:
163
+ """Serve a human-readable HTML status page showing component health."""
164
+ import time as _time
165
+ from mannf.product.server import _scans, _server_start_time
166
+ from mannf.product.status import aggregate_status, run_all_checks
167
+
168
+ components = await run_all_checks(_scans, _server_start_time)
169
+ overall = aggregate_status(components)
170
+ uptime_s = _time.monotonic() - _server_start_time
171
+
172
+ def _status_colour(s: str) -> str:
173
+ return {"healthy": "#22c55e", "degraded": "#f59e0b", "unhealthy": "#ef4444", "disabled": "#9ca3af"}.get(s, "#9ca3af")
174
+
175
+ def _badge(s: str) -> str:
176
+ colour = _status_colour(s)
177
+ return f'<span style="background:{colour};color:#fff;padding:2px 8px;border-radius:4px;font-size:0.85em">{s.upper()}</span>'
178
+
179
+ rows = ""
180
+ for comp in components:
181
+ latency = f"{comp.latency_ms:.1f} ms" if comp.latency_ms else "—"
182
+ details_html = ", ".join(f"<b>{k}</b>: {v}" for k, v in comp.details.items())
183
+ rows += f"""
184
+ <tr>
185
+ <td style="padding:8px 12px">{comp.name}</td>
186
+ <td style="padding:8px 12px">{_badge(comp.status)}</td>
187
+ <td style="padding:8px 12px">{latency}</td>
188
+ <td style="padding:8px 12px;font-size:0.85em;color:#6b7280">{details_html}</td>
189
+ </tr>"""
190
+
191
+ overall_colour = _status_colour(overall)
192
+ from mannf import __version__
193
+
194
+ html = f"""<!DOCTYPE html>
195
+ <html lang="en">
196
+ <head>
197
+ <meta charset="UTF-8">
198
+ <meta name="viewport" content="width=device-width,initial-scale=1">
199
+ <title>NAT Status</title>
200
+ <style>
201
+ body {{ font-family: system-ui, sans-serif; margin: 0; background: #f9fafb; color: #111827; }}
202
+ header {{ background: #1e293b; color: #fff; padding: 16px 24px; display: flex; align-items: center; gap: 12px; }}
203
+ header h1 {{ margin: 0; font-size: 1.25rem; }}
204
+ .container {{ max-width: 960px; margin: 32px auto; padding: 0 16px; }}
205
+ .card {{ background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); margin-bottom: 24px; overflow: hidden; }}
206
+ .card-header {{ padding: 16px 20px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }}
207
+ table {{ width: 100%; border-collapse: collapse; }}
208
+ th {{ background: #f3f4f6; text-align: left; padding: 8px 12px; font-size: 0.8rem; text-transform: uppercase; color: #6b7280; }}
209
+ tr:nth-child(even) {{ background: #f9fafb; }}
210
+ .overall {{ display: inline-block; width: 16px; height: 16px; border-radius: 50%; background: {overall_colour}; vertical-align: middle; margin-right: 8px; }}
211
+ .meta {{ font-size: 0.85rem; color: #6b7280; margin-top: 4px; }}
212
+ a {{ color: #3b82f6; text-decoration: none; }}
213
+ a:hover {{ text-decoration: underline; }}
214
+ </style>
215
+ </head>
216
+ <body>
217
+ <header>
218
+ <div style="font-size:1.5rem">&#x1F9E0;</div>
219
+ <h1>NAT — System Status</h1>
220
+ <a href="/dashboard" style="margin-left:auto;color:#93c5fd;font-size:0.9rem">&#x2190; Dashboard</a>
221
+ </header>
222
+ <div class="container">
223
+ <div class="card">
224
+ <div class="card-header">
225
+ <span class="overall"></span>
226
+ Overall: <strong style="color:{overall_colour}">{overall.upper()}</strong>
227
+ &nbsp;&nbsp;<span class="meta">v{__version__} &mdash; uptime {int(uptime_s // 3600)}h {int((uptime_s % 3600) // 60)}m {int(uptime_s % 60)}s</span>
228
+ </div>
229
+ <table>
230
+ <thead>
231
+ <tr>
232
+ <th>Component</th><th>Status</th><th>Latency</th><th>Details</th>
233
+ </tr>
234
+ </thead>
235
+ <tbody>{rows}</tbody>
236
+ </table>
237
+ </div>
238
+ <p style="text-align:center;font-size:0.8rem;color:#9ca3af">
239
+ Machine-readable JSON: <a href="/api/v1/status">/api/v1/status</a>
240
+ </p>
241
+ </div>
242
+ </body>
243
+ </html>"""
244
+ return HTMLResponse(content=html)
245
+
246
+
247
+ @router.get("/manifest.json")
248
+ async def pwa_manifest() -> Response:
249
+ """Serve the PWA web app manifest."""
250
+ path = _STATIC_DIR / "manifest.json"
251
+ return Response(
252
+ content=path.read_bytes(),
253
+ media_type="application/manifest+json",
254
+ headers={"Cache-Control": "public, max-age=86400"},
255
+ )
256
+
257
+
258
+ @router.get("/sw.js")
259
+ async def service_worker() -> Response:
260
+ """Serve the PWA service worker script at the root scope."""
261
+ path = _STATIC_DIR / "sw.js"
262
+ return Response(
263
+ content=path.read_bytes(),
264
+ media_type="application/javascript",
265
+ headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"},
266
+ )
267
+
268
+
269
+ @router.get("/pwa-icon-192.png")
270
+ async def pwa_icon_192() -> Response:
271
+ """Serve the 192×192 PWA app icon."""
272
+ path = _STATIC_DIR / "pwa-icon-192.png"
273
+ return Response(
274
+ content=path.read_bytes(),
275
+ media_type="image/png",
276
+ headers={"Cache-Control": "public, max-age=604800"},
277
+ )
278
+
279
+
280
+ @router.get("/pwa-icon-512.png")
281
+ async def pwa_icon_512() -> Response:
282
+ """Serve the 512×512 PWA app icon."""
283
+ path = _STATIC_DIR / "pwa-icon-512.png"
284
+ return Response(
285
+ content=path.read_bytes(),
286
+ media_type="image/png",
287
+ headers={"Cache-Control": "public, max-age=604800"},
288
+ )
289
+
290
+
291
+ @router.get("/api/v1/dashboard/summary", response_model=DashboardSummary)
292
+ async def dashboard_summary() -> DashboardSummary:
293
+ """Return aggregated statistics for the dashboard overview bar."""
294
+ # Import here to avoid circular imports with server.py
295
+ from mannf.product.server import _scans, _get_anomaly_detector, _get_risk_scorer
296
+
297
+ # --- scan stats ---------------------------------------------------------
298
+ total_scans = len(_scans)
299
+ total_tests_run = 0
300
+ total_passed = 0
301
+ last_scan_at: str | None = None
302
+
303
+ for entry in _scans.values():
304
+ summary = entry.get("summary") or {}
305
+ total_tests_run += summary.get("total", 0)
306
+ total_passed += summary.get("passed", 0)
307
+ completed_at = entry.get("completed_at")
308
+ if completed_at:
309
+ if last_scan_at is None or completed_at > last_scan_at:
310
+ last_scan_at = completed_at
311
+
312
+ pass_rate = total_passed / total_tests_run if total_tests_run > 0 else 0.0
313
+
314
+ # --- risk stats ---------------------------------------------------------
315
+ scorer = _get_risk_scorer()
316
+ risk_report: List[Dict[str, Any]] = scorer.get_risk_report()
317
+ endpoints_discovered = len(risk_report)
318
+ avg_risk_score = (
319
+ sum(e["risk_score"] for e in risk_report) / endpoints_discovered
320
+ if endpoints_discovered > 0
321
+ else 0.0
322
+ )
323
+ # Health score is inverse of avg risk
324
+ health_score = max(0.0, 1.0 - avg_risk_score)
325
+
326
+ # --- anomaly stats ------------------------------------------------------
327
+ detector = _get_anomaly_detector()
328
+ all_alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
329
+ anomalies_found = len(all_alerts)
330
+ active_anomalies = sum(
331
+ 1 for a in all_alerts if a.get("severity") in ("warning", "critical")
332
+ )
333
+
334
+ return DashboardSummary(
335
+ total_scans=total_scans,
336
+ endpoints_discovered=endpoints_discovered,
337
+ anomalies_found=anomalies_found,
338
+ avg_risk_score=round(avg_risk_score, 4),
339
+ total_tests_run=total_tests_run,
340
+ pass_rate=round(pass_rate, 4),
341
+ active_anomalies=active_anomalies,
342
+ last_scan_at=last_scan_at,
343
+ health_score=round(health_score, 4),
344
+ )
345
+
346
+
347
+ @router.get("/api/v1/dashboard/endpoints", response_model=List[EndpointRiskCard])
348
+ async def dashboard_endpoints() -> List[EndpointRiskCard]:
349
+ """Return enriched per-endpoint cards for the risk heatmap."""
350
+ from mannf.product.server import _get_anomaly_detector, _get_risk_scorer
351
+
352
+ scorer = _get_risk_scorer()
353
+ detector = _get_anomaly_detector()
354
+ risk_report: List[Dict[str, Any]] = scorer.get_risk_report()
355
+ all_alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
356
+
357
+ # Count anomalies per endpoint
358
+ anomaly_counts: Dict[str, int] = {}
359
+ for alert in all_alerts:
360
+ ep = alert.get("endpoint", "")
361
+ anomaly_counts[ep] = anomaly_counts.get(ep, 0) + 1
362
+
363
+ cards: List[EndpointRiskCard] = []
364
+ for entry in risk_report:
365
+ ep = entry["endpoint"]
366
+ cards.append(
367
+ EndpointRiskCard(
368
+ endpoint=ep,
369
+ risk_score=entry["risk_score"],
370
+ fault_rate=entry["fault_rate"],
371
+ belief_signal=entry["belief_signal"],
372
+ latency_signal=entry["latency_signal"],
373
+ diversity_signal=entry["diversity_signal"],
374
+ trend=entry["trend"],
375
+ run_count=entry["run_count"],
376
+ fault_count=entry["fault_count"],
377
+ anomaly_count=anomaly_counts.get(ep, 0),
378
+ )
379
+ )
380
+ return cards
381
+
382
+
383
+ @router.get("/api/v1/dashboard/anomalies", response_model=List[AnomalyTimelineEntry])
384
+ async def dashboard_anomalies() -> List[AnomalyTimelineEntry]:
385
+ """Return anomaly timeline entries sorted newest-first."""
386
+ from mannf.product.server import _get_anomaly_detector
387
+
388
+ detector = _get_anomaly_detector()
389
+ alerts: List[Dict[str, Any]] = detector.get_alerts_dict()
390
+ entries = [AnomalyTimelineEntry(**a) for a in alerts]
391
+ # Sort newest-first
392
+ entries.sort(key=lambda e: e.detected_at, reverse=True)
393
+ return entries
394
+
395
+
396
+ @router.get("/api/v1/dashboard/agents", response_model=List[BeliefStateSnapshot])
397
+ async def dashboard_agents() -> List[BeliefStateSnapshot]:
398
+ """Return the most recent telemetry snapshot for each active agent.
399
+
400
+ Intended for the initial page load before the WebSocket connects.
401
+ Each entry is the last ``agent_state`` snapshot emitted after a
402
+ deliberation cycle.
403
+ """
404
+ from mannf.product.dashboard.telemetry import get_agent_snapshots
405
+
406
+ snapshots = get_agent_snapshots()
407
+ result: List[BeliefStateSnapshot] = []
408
+ for snap in snapshots:
409
+ confidence_map: Dict[str, Any] = snap.get("confidence", {})
410
+ avg_confidence = (
411
+ sum(confidence_map.values()) / len(confidence_map)
412
+ if confidence_map
413
+ else 0.5
414
+ )
415
+ result.append(
416
+ BeliefStateSnapshot(
417
+ agent_id=snap.get("agent_id", ""),
418
+ agent_role=snap.get("agent_role", ""),
419
+ beliefs=snap.get("beliefs", {}),
420
+ active=True,
421
+ confidence=round(avg_confidence, 4),
422
+ confidence_per_service=confidence_map,
423
+ intentions=snap.get("intentions", []),
424
+ desires=snap.get("desires", {}),
425
+ entropy=snap.get("entropy", 0.0),
426
+ timestamp=snap.get("timestamp"),
427
+ )
428
+ )
429
+ return result
430
+
431
+
432
+ @router.get("/api/v1/dashboard/settings")
433
+ async def dashboard_settings() -> Dict[str, Any]:
434
+ """Return read-only server configuration for the Settings tab."""
435
+ api_key_raw = os.environ.get("NAT_API_KEY", "")
436
+ if len(api_key_raw) > 4:
437
+ api_key_preview = "*" * (len(api_key_raw) - 4) + api_key_raw[-4:]
438
+ elif api_key_raw:
439
+ api_key_preview = "***"
440
+ else:
441
+ api_key_preview = ""
442
+
443
+ return {
444
+ "server": {
445
+ "host": os.environ.get("NAT_HOST", "0.0.0.0"),
446
+ "port": int(os.environ.get("NAT_PORT", "8080")),
447
+ "workers": int(os.environ.get("WEB_CONCURRENCY", "1")),
448
+ "log_level": os.environ.get("NAT_LOG_LEVEL", "info"),
449
+ },
450
+ "auth": {
451
+ "api_key_configured": bool(api_key_raw),
452
+ "api_key_preview": api_key_preview,
453
+ },
454
+ "queue": {
455
+ "max_concurrent_scans": int(os.environ.get("NAT_MAX_CONCURRENT_SCANS", "4")),
456
+ "max_queue_depth": int(os.environ.get("NAT_MAX_QUEUE_DEPTH", "20")),
457
+ "scan_timeout_seconds": int(os.environ.get("NAT_SCAN_TIMEOUT", "300")),
458
+ },
459
+ "rate_limit": os.environ.get("NAT_RATE_LIMIT", ""),
460
+ "webhook": {
461
+ "default_url": os.environ.get("NAT_WEBHOOK_URL", ""),
462
+ "hmac_configured": bool(os.environ.get("NAT_WEBHOOK_SECRET", "")),
463
+ },
464
+ }
465
+
466
+
467
+ def _render_export_html(
468
+ scan_id: str,
469
+ entry: Dict[str, Any],
470
+ summary: Dict[str, Any],
471
+ results: List[Any],
472
+ ) -> str:
473
+ """Generate a standalone HTML report for a scan export."""
474
+ status = entry.get("status", "unknown")
475
+ spec_name = entry.get("spec_name", "—")
476
+ base_url = entry.get("base_url", "—")
477
+ created_at = entry.get("created_at", "—")
478
+ completed_at = entry.get("completed_at", "—")
479
+ total = summary.get("total", 0)
480
+ passed = summary.get("passed", 0)
481
+ failed = summary.get("failed", 0)
482
+ pass_rate = summary.get("pass_rate", 0.0)
483
+
484
+ status_color = "#3fb950" if status == "completed" else ("#f85149" if status == "failed" else "#8b949e")
485
+
486
+ rows_html = ""
487
+ for r in results:
488
+ if hasattr(r, "__dict__"):
489
+ r = r.__dict__
490
+ tc_id = r.get("test_case_id", "")
491
+ ep = r.get("endpoint", "")
492
+ ok = r.get("passed", False)
493
+ sc = r.get("status_code", "")
494
+ lat = r.get("execution_time_ms", "")
495
+ err = r.get("error") or ""
496
+ row_bg = "#1a2e1a" if ok else "#2e1a1a"
497
+ badge = '<span style="color:#3fb950;font-weight:700;">PASS</span>' if ok else '<span style="color:#f85149;font-weight:700;">FAIL</span>'
498
+ rows_html += f"""<tr style="background:{row_bg};">
499
+ <td style="padding:6px 10px;font-family:monospace;font-size:12px;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{tc_id}</td>
500
+ <td style="padding:6px 10px;font-family:monospace;font-size:12px;">{ep}</td>
501
+ <td style="padding:6px 10px;text-align:center;">{badge}</td>
502
+ <td style="padding:6px 10px;text-align:center;">{sc}</td>
503
+ <td style="padding:6px 10px;text-align:right;">{lat}</td>
504
+ <td style="padding:6px 10px;font-size:11px;color:#8b949e;">{err}</td>
505
+ </tr>"""
506
+
507
+ report_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
508
+ return f"""<!DOCTYPE html>
509
+ <html lang="en">
510
+ <head>
511
+ <meta charset="UTF-8" />
512
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
513
+ <title>NAT Scan Report — {scan_id}</title>
514
+ <style>
515
+ body {{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:#0d1117;color:#e6edf3;margin:0;padding:24px;font-size:14px;}}
516
+ h1 {{font-size:20px;margin-bottom:4px;}}
517
+ .meta {{color:#8b949e;font-size:12px;margin-bottom:24px;}}
518
+ .cards {{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:24px;}}
519
+ .card {{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px 20px;min-width:120px;}}
520
+ .card-label {{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b949e;margin-bottom:4px;}}
521
+ .card-value {{font-size:28px;font-weight:700;line-height:1;}}
522
+ table {{width:100%;border-collapse:collapse;font-size:13px;}}
523
+ th {{text-align:left;padding:8px 10px;color:#8b949e;border-bottom:1px solid #30363d;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em;}}
524
+ td {{border-bottom:1px solid rgba(48,54,61,.5);}}
525
+ .footer {{margin-top:24px;color:#484f58;font-size:11px;}}
526
+ </style>
527
+ </head>
528
+ <body>
529
+ <h1>🔬 NAT Scan Report</h1>
530
+ <div class="meta">Generated {report_timestamp} · Scan ID: {scan_id}</div>
531
+ <div class="cards">
532
+ <div class="card">
533
+ <div class="card-label">Status</div>
534
+ <div class="card-value" style="font-size:18px;color:{status_color};">{status}</div>
535
+ </div>
536
+ <div class="card">
537
+ <div class="card-label">Total Tests</div>
538
+ <div class="card-value">{total}</div>
539
+ </div>
540
+ <div class="card">
541
+ <div class="card-label">Passed</div>
542
+ <div class="card-value" style="color:#3fb950;">{passed}</div>
543
+ </div>
544
+ <div class="card">
545
+ <div class="card-label">Failed</div>
546
+ <div class="card-value" style="color:#f85149;">{failed}</div>
547
+ </div>
548
+ <div class="card">
549
+ <div class="card-label">Pass Rate</div>
550
+ <div class="card-value">{pass_rate * 100:.1f}%</div>
551
+ </div>
552
+ </div>
553
+ <table style="background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden;">
554
+ <thead>
555
+ <tr>
556
+ <th>Test Case ID</th>
557
+ <th>Endpoint</th>
558
+ <th>Result</th>
559
+ <th>Status Code</th>
560
+ <th>Latency (ms)</th>
561
+ <th>Error</th>
562
+ </tr>
563
+ </thead>
564
+ <tbody>
565
+ {rows_html if rows_html else '<tr><td colspan="6" style="padding:16px;text-align:center;color:#484f58;">No test results recorded</td></tr>'}
566
+ </tbody>
567
+ </table>
568
+ <div class="footer">
569
+ Spec: {spec_name} · Base URL: {base_url} · Started: {created_at} · Completed: {completed_at}
570
+ </div>
571
+ </body>
572
+ </html>"""
573
+
574
+
575
+ @router.get("/api/v1/scan/{scan_id}/export")
576
+ async def export_scan(
577
+ scan_id: str,
578
+ format: str = Query("json", description="Export format: html, json, csv, or pdf"),
579
+ request: "Any" = None,
580
+ ) -> Response:
581
+ """Export scan results in html, json, csv, or pdf format.
582
+
583
+ - ``html`` — standalone printable HTML report with embedded styles
584
+ - ``json`` — full machine-readable scan data
585
+ - ``csv`` — tabular test results for spreadsheet analysis
586
+ - ``pdf`` — PDF report (Pro+ plans only; Enterprise supports white-label)
587
+ """
588
+ if format not in ("html", "json", "csv", "pdf"):
589
+ raise HTTPException(status_code=400, detail="format must be one of: html, json, csv, pdf")
590
+
591
+ from mannf.product.server import _scans
592
+
593
+ entry = _scans.get(scan_id)
594
+ if entry is None:
595
+ raise HTTPException(status_code=404, detail=f"Scan '{scan_id}' not found")
596
+
597
+ summary: Dict[str, Any] = entry.get("summary") or {}
598
+ results: List[Any] = entry.get("results") or []
599
+
600
+ if format == "json":
601
+ content = json.dumps(entry, indent=2, default=str)
602
+ return Response(
603
+ content=content,
604
+ media_type="application/json",
605
+ headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.json"'},
606
+ )
607
+
608
+ if format == "csv":
609
+ output = io.StringIO()
610
+ writer = csv.writer(output)
611
+ writer.writerow(["test_case_id", "endpoint", "passed", "status_code", "execution_time_ms", "error"])
612
+ for r in results:
613
+ if hasattr(r, "__dict__"):
614
+ r = r.__dict__
615
+ writer.writerow([
616
+ r.get("test_case_id", ""),
617
+ r.get("endpoint", ""),
618
+ r.get("passed", ""),
619
+ r.get("status_code", ""),
620
+ r.get("execution_time_ms", ""),
621
+ r.get("error") or "",
622
+ ])
623
+ return Response(
624
+ content=output.getvalue(),
625
+ media_type="text/csv",
626
+ headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.csv"'},
627
+ )
628
+
629
+ if format == "pdf":
630
+ from mannf.product.billing.feature_gates import check_feature, get_features # noqa: PLC0415
631
+
632
+ tenant = None
633
+ if request is not None:
634
+ tenant = getattr(getattr(request, "state", None), "tenant", None)
635
+ plan_tier: str = tenant.get("plan_tier", "free") if tenant else "free"
636
+
637
+ if not check_feature(plan_tier, "export_pdf"):
638
+ raise HTTPException(
639
+ status_code=403,
640
+ detail={
641
+ "code": "FEATURE_NOT_AVAILABLE",
642
+ "message": (
643
+ "PDF export is not available on the free plan. "
644
+ "Upgrade to Pro or higher to download PDF reports."
645
+ ),
646
+ "feature": "export_pdf",
647
+ "plan": plan_tier,
648
+ "upgrade_url": "https://app.nat-testing.io/dashboard/upgrade?to=pro",
649
+ },
650
+ )
651
+
652
+ whitelabel: bool = bool(get_features(plan_tier).get("pdf_whitelabel", False))
653
+
654
+ from mannf.product.reports.pdf import PDFReportGenerator # noqa: PLC0415
655
+
656
+ html = _render_export_html(scan_id, entry, summary, results)
657
+ generator = PDFReportGenerator()
658
+ pdf_bytes = await generator.generate(html, whitelabel=whitelabel)
659
+ return Response(
660
+ content=pdf_bytes,
661
+ media_type="application/pdf",
662
+ headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.pdf"'},
663
+ )
664
+
665
+ # html
666
+ html = _render_export_html(scan_id, entry, summary, results)
667
+ return Response(
668
+ content=html,
669
+ media_type="text/html",
670
+ headers={"Content-Disposition": f'attachment; filename="scan-{scan_id}.html"'},
671
+ )
672
+
673
+
674
+ @router.get("/api/v1/dashboard/functional-results", response_model=FunctionalTestResult)
675
+ async def dashboard_functional_results() -> FunctionalTestResult:
676
+ """Return the latest FunctionalTestOrchestrator run results for the dashboard.
677
+
678
+ Pulls data from the server's ``_functional_results`` store when available.
679
+ Returns an empty result with helpful defaults when no runs exist yet.
680
+ """
681
+ try:
682
+ from mannf.product.server import _functional_results # noqa: PLC0415
683
+ except ImportError:
684
+ _functional_results: List[Dict[str, Any]] = []
685
+
686
+ if not _functional_results:
687
+ return FunctionalTestResult()
688
+
689
+ latest = _functional_results[-1]
690
+ from mannf.product.dashboard.models import ( # noqa: PLC0415
691
+ A11yViolationSummary,
692
+ FunctionalTestItem,
693
+ PerfMetrics,
694
+ VisualDiffEntry,
695
+ )
696
+
697
+ total = latest.get("total", 0)
698
+ passed = latest.get("passed", 0)
699
+ failed = latest.get("failed", total - passed)
700
+ pass_rate = round(passed / total, 4) if total > 0 else 0.0
701
+
702
+ items = [
703
+ FunctionalTestItem(
704
+ task_id=item.get("task_id", ""),
705
+ task_name=item.get("task_name", item.get("task_id", "")),
706
+ passed=item.get("passed", False),
707
+ duration_ms=item.get("duration_ms"),
708
+ error=item.get("error"),
709
+ artifact_url=item.get("artifact_url"),
710
+ )
711
+ for item in latest.get("items", [])
712
+ ]
713
+
714
+ visual_diffs = [
715
+ VisualDiffEntry(
716
+ task_id=vd.get("task_id", ""),
717
+ baseline_url=vd.get("baseline_url"),
718
+ current_url=vd.get("current_url"),
719
+ diff_url=vd.get("diff_url"),
720
+ diff_percent=vd.get("diff_percent", 0.0),
721
+ passed=vd.get("passed", True),
722
+ )
723
+ for vd in latest.get("visual_diffs", [])
724
+ ]
725
+
726
+ a11y_data = latest.get("a11y")
727
+ a11y = (
728
+ A11yViolationSummary(
729
+ critical=a11y_data.get("critical", 0),
730
+ serious=a11y_data.get("serious", 0),
731
+ moderate=a11y_data.get("moderate", 0),
732
+ minor=a11y_data.get("minor", 0),
733
+ total=a11y_data.get("total", 0),
734
+ sample_violations=a11y_data.get("sample_violations", []),
735
+ )
736
+ if a11y_data
737
+ else None
738
+ )
739
+
740
+ perf_data = latest.get("perf")
741
+ perf = (
742
+ PerfMetrics(
743
+ lcp_ms=perf_data.get("lcp_ms"),
744
+ fid_ms=perf_data.get("fid_ms"),
745
+ cls=perf_data.get("cls"),
746
+ ttfb_ms=perf_data.get("ttfb_ms"),
747
+ )
748
+ if perf_data
749
+ else None
750
+ )
751
+
752
+ return FunctionalTestResult(
753
+ run_id=latest.get("run_id"),
754
+ started_at=latest.get("started_at"),
755
+ completed_at=latest.get("completed_at"),
756
+ total=total,
757
+ passed=passed,
758
+ failed=failed,
759
+ pass_rate=pass_rate,
760
+ items=items,
761
+ visual_diffs=visual_diffs,
762
+ a11y=a11y,
763
+ perf=perf,
764
+ )
765
+
766
+
767
+ @router.get("/api/v1/dashboard/browser-security-findings")
768
+ async def dashboard_browser_security_findings() -> Dict[str, Any]:
769
+ """Return browser-level security findings from autonomous run(s).
770
+
771
+ Aggregates findings collected by the
772
+ :class:`~mannf.core.browser.reflection_analyzer.ReflectionAnalyzer`
773
+ across all autonomous run reports in the in-memory store.
774
+
775
+ Returns a JSON object with:
776
+
777
+ * ``findings`` — list of finding dicts (matching the
778
+ :class:`~mannf.product.security.models.SecurityFinding` field layout).
779
+ * ``summary`` — counts by severity.
780
+ * ``total_findings`` — total number of findings.
781
+ """
782
+ all_findings: list[Dict[str, Any]] = []
783
+
784
+ for entry in _autonomous_runs.values():
785
+ # findings may be attached at the top level or inside the report
786
+ raw_report = entry if isinstance(entry, dict) else {}
787
+ coverage = raw_report.get("coverage_summary", {})
788
+ # Collected findings are stored on the agent but we surface them
789
+ # from the in-memory demo data and any live run data.
790
+ findings_in_entry = raw_report.get("browser_security_findings", [])
791
+ all_findings.extend(findings_in_entry)
792
+
793
+ # Include demo findings when no live data exists
794
+ if not all_findings:
795
+ from mannf.product.demo import _DEMO_BROWSER_SECURITY_FINDINGS # noqa: PLC0415
796
+ all_findings = list(_DEMO_BROWSER_SECURITY_FINDINGS)
797
+
798
+ severity_order = ["critical", "high", "medium", "low", "info"]
799
+ summary: Dict[str, int] = {s: 0 for s in severity_order}
800
+ for f in all_findings:
801
+ sev = f.get("severity", "info")
802
+ if sev in summary:
803
+ summary[sev] += 1
804
+
805
+ return {
806
+ "findings": all_findings,
807
+ "summary": summary,
808
+ "total_findings": len(all_findings),
809
+ }
810
+
811
+
812
+ @router.get("/api/v1/dashboard/usage-summary", response_model=UsageSummary)
813
+ async def dashboard_usage_summary(request: "Any" = None) -> UsageSummary:
814
+ """Return usage gauge data (wraps /api/v1/usage) for the billing tab.
815
+
816
+ When a tenant is authenticated the data is tenant-scoped; otherwise
817
+ returns quota data from the server's environment configuration.
818
+ """
819
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
820
+ from mannf.product.dashboard.models import InvoiceEntry # noqa: PLC0415
821
+
822
+ # Attempt to resolve the tenant from the request state (may be None in
823
+ # environments without full auth middleware)
824
+ tenant = None
825
+ if request is not None:
826
+ tenant = getattr(getattr(request, "state", None), "tenant", None)
827
+
828
+ if tenant is not None:
829
+ from mannf.product.billing.tenant_auth import get_monthly_usage # noqa: PLC0415
830
+ import uuid as _uuid # noqa: PLC0415
831
+
832
+ tenant_id = _uuid.UUID(tenant["id"])
833
+ plan_tier = tenant.get("plan_tier", "free")
834
+ plan = get_plan(plan_tier)
835
+ func_used = await get_monthly_usage(tenant_id, "functional")
836
+ sec_used = await get_monthly_usage(tenant_id, "security")
837
+ else:
838
+ plan_tier = "free"
839
+ plan = get_plan(plan_tier)
840
+ func_used = 0
841
+ sec_used = 0
842
+
843
+ func_quota = plan.monthly_scan_quota
844
+ sec_quota = plan.monthly_security_scan_quota
845
+ func_pct = round(func_used / func_quota, 4) if func_quota > 0 else 0.0
846
+ sec_pct = round(sec_used / sec_quota, 4) if sec_quota > 0 else 0.0
847
+ approaching = func_pct >= 0.7 or sec_pct >= 0.7
848
+
849
+ # Quota resets on the 1st of the next month
850
+ now = datetime.now(timezone.utc)
851
+ if now.month == 12:
852
+ reset_dt = now.replace(year=now.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
853
+ else:
854
+ reset_dt = now.replace(month=now.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0)
855
+ quota_reset_date = reset_dt.strftime("%Y-%m-%d")
856
+
857
+ return UsageSummary(
858
+ plan_name=plan.display_name,
859
+ plan_tier=plan_tier,
860
+ functional_used=func_used,
861
+ functional_quota=func_quota,
862
+ security_used=sec_used,
863
+ security_quota=sec_quota,
864
+ functional_percent=func_pct,
865
+ security_percent=sec_pct,
866
+ approaching_limit=approaching,
867
+ quota_reset_date=quota_reset_date,
868
+ stripe_portal_url=None,
869
+ invoices=[],
870
+ )
871
+
872
+
873
+ @router.get("/api/v1/dashboard/onboarding-status", response_model=OnboardingStatus)
874
+ async def dashboard_onboarding_status(request: "Any" = None) -> OnboardingStatus:
875
+ """Return onboarding checklist completion state for the getting-started tab.
876
+
877
+ Steps are derived from the tenant's activity: whether they have an API
878
+ key configured, have run at least one scan, and have viewed results.
879
+ """
880
+ from mannf.product.server import _scans # noqa: PLC0415
881
+
882
+ tenant = None
883
+ if request is not None:
884
+ tenant = getattr(getattr(request, "state", None), "tenant", None)
885
+
886
+ api_key_raw = os.environ.get("NAT_API_KEY", "")
887
+ has_api_key = bool(api_key_raw) or (tenant is not None)
888
+ has_run_scan = len(_scans) > 0
889
+ has_viewed_results = has_run_scan # proxy: if they ran a scan they've seen results
890
+
891
+ # Build masked API key for display
892
+ api_key_masked: Optional[str] = None
893
+ if api_key_raw:
894
+ visible = api_key_raw[-4:] if len(api_key_raw) >= 4 else api_key_raw
895
+ api_key_masked = "nat_pk_" + "•" * 16 + visible
896
+ elif tenant:
897
+ api_key_masked = "nat_pk_" + "•" * 20
898
+
899
+ steps: List[OnboardingStep] = [
900
+ OnboardingStep(
901
+ step=1,
902
+ title="Account created",
903
+ description="Your NAT account is set up and ready to use.",
904
+ completed=True,
905
+ ),
906
+ OnboardingStep(
907
+ step=2,
908
+ title="Copy your API key",
909
+ description="Use this key to authenticate CLI and API requests.",
910
+ completed=has_api_key,
911
+ action_label="View API key",
912
+ action_url="/dashboard#settings",
913
+ ),
914
+ OnboardingStep(
915
+ step=3,
916
+ title="Install NAT CLI",
917
+ description="Install the NAT testing toolkit with pip.",
918
+ completed=False,
919
+ code_snippet="pip install nat-testing",
920
+ ),
921
+ OnboardingStep(
922
+ step=4,
923
+ title="Run your first scan",
924
+ description="Point NAT at your OpenAPI spec and base URL.",
925
+ completed=has_run_scan,
926
+ code_snippet="nat scan --spec openapi.yaml --url https://api.example.com",
927
+ ),
928
+ OnboardingStep(
929
+ step=5,
930
+ title="View results in dashboard",
931
+ description="Explore your scan results, risk heatmap, and agent activity.",
932
+ completed=has_viewed_results,
933
+ action_label="Go to Overview",
934
+ action_url="/dashboard#overview",
935
+ ),
936
+ ]
937
+
938
+ completed_count = sum(1 for s in steps if s.completed)
939
+ return OnboardingStatus(
940
+ steps=steps,
941
+ completed_count=completed_count,
942
+ total_count=len(steps),
943
+ api_key_masked=api_key_masked,
944
+ )
945
+
946
+
947
+ @router.get(
948
+ "/api/v1/dashboard/onboarding",
949
+ tags=["dashboard", "onboarding"],
950
+ summary="Onboarding progress for the authenticated tenant (dashboard view)",
951
+ )
952
+ async def dashboard_onboarding(request: "Any" = None) -> dict:
953
+ """Return the onboarding progress for the current tenant using the tracker.
954
+
955
+ Delegates to :mod:`mannf.product.onboarding` for per-step state, with a
956
+ fallback to the static checklist when no tenant is authenticated.
957
+ """
958
+ from mannf.product.onboarding import get_onboarding_status # noqa: PLC0415
959
+
960
+ tenant = None
961
+ if request is not None:
962
+ tenant = getattr(getattr(request, "state", None), "tenant", None)
963
+
964
+ if tenant is None:
965
+ # No authenticated tenant — return an empty/default status
966
+ return {
967
+ "tenant_id": None,
968
+ "completed_steps": [],
969
+ "next_step": "ACCOUNT_CREATED",
970
+ "progress_percent": 0.0,
971
+ "all_steps": [
972
+ {"step": s, "completed": False, "completed_at": None}
973
+ for s in [
974
+ "ACCOUNT_CREATED",
975
+ "API_KEY_PROVISIONED",
976
+ "FIRST_SCAN_STARTED",
977
+ "FIRST_SCAN_COMPLETED",
978
+ "RESULTS_VIEWED",
979
+ "BILLING_CONFIGURED",
980
+ ]
981
+ ],
982
+ }
983
+
984
+ status = get_onboarding_status(tenant["id"])
985
+ return status.model_dump()
986
+
987
+
988
+ # ---------------------------------------------------------------------------
989
+ # Go-live indicators — plan badge, usage bars, upgrade CTA, demo banner
990
+ # (Phase 8.5 — Go-to-Market Readiness)
991
+ # ---------------------------------------------------------------------------
992
+
993
+
994
+ @router.get(
995
+ "/api/v1/dashboard/go-live-status",
996
+ tags=["dashboard"],
997
+ summary="Go-live readiness indicators for the current tenant",
998
+ )
999
+ async def dashboard_go_live_status(request: "Any" = None) -> JSONResponse:
1000
+ """Return plan badge, usage progress, upgrade CTA, and demo-mode info.
1001
+
1002
+ Used by the dashboard to render plan tier badge, usage progress bars,
1003
+ "Upgrade" CTA for free/pro users, trial countdown (if applicable), and
1004
+ the demo environment banner when ``NAT_DEMO_MODE=true``.
1005
+
1006
+ Degrades gracefully when billing is not configured.
1007
+ """
1008
+ from mannf.product.billing.plans import get_plan # noqa: PLC0415
1009
+
1010
+ tenant = None
1011
+ if request is not None:
1012
+ tenant = getattr(getattr(request, "state", None), "tenant", None)
1013
+
1014
+ # Demo mode
1015
+ raw_demo = os.environ.get("NAT_DEMO_MODE", "").lower().strip()
1016
+ is_demo = raw_demo in ("true", "1", "yes")
1017
+
1018
+ # Billing availability (degrades gracefully when Stripe not configured)
1019
+ stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip())
1020
+ billing_enabled = stripe_key_set
1021
+
1022
+ if tenant is not None:
1023
+ plan_tier = tenant.get("plan_tier", "free")
1024
+ tenant_name = tenant.get("name", "")
1025
+ monthly_scan_quota = tenant.get("monthly_scan_quota", 50)
1026
+ monthly_security_scan_quota = tenant.get("monthly_security_scan_quota", 10)
1027
+ trial_ends_at = tenant.get("trial_ends_at")
1028
+ else:
1029
+ plan_tier = "free"
1030
+ tenant_name = ""
1031
+ plan_obj = get_plan("free")
1032
+ monthly_scan_quota = plan_obj.monthly_scan_quota
1033
+ monthly_security_scan_quota = plan_obj.monthly_security_scan_quota
1034
+ trial_ends_at = None
1035
+
1036
+ plan_obj = get_plan(plan_tier)
1037
+
1038
+ # Fetch usage if possible
1039
+ func_used = 0
1040
+ sec_used = 0
1041
+ if tenant is not None:
1042
+ try:
1043
+ from mannf.product.billing.tenant_auth import get_monthly_usage # noqa: PLC0415
1044
+ import uuid as _uuid # noqa: PLC0415
1045
+
1046
+ tenant_id = _uuid.UUID(tenant["id"])
1047
+ func_used = await get_monthly_usage(tenant_id, "functional")
1048
+ sec_used = await get_monthly_usage(tenant_id, "security")
1049
+ except Exception: # noqa: BLE001
1050
+ pass
1051
+
1052
+ func_pct = round(func_used / monthly_scan_quota, 4) if monthly_scan_quota > 0 else 0.0
1053
+ sec_pct = round(sec_used / monthly_security_scan_quota, 4) if monthly_security_scan_quota > 0 else 0.0
1054
+
1055
+ # Upgrade CTA: show for free/pro users
1056
+ show_upgrade_cta = plan_tier in ("free", "pro") and billing_enabled
1057
+ upgrade_tier = "pro" if plan_tier == "free" else "team"
1058
+ upgrade_url = f"https://app.nat-testing.io/dashboard/upgrade?to={upgrade_tier}" if show_upgrade_cta else None
1059
+
1060
+ # Trial countdown
1061
+ trial_countdown_days: Optional[int] = None
1062
+ if trial_ends_at:
1063
+ try:
1064
+ from datetime import datetime, timezone # noqa: PLC0415
1065
+ if isinstance(trial_ends_at, str):
1066
+ ends = datetime.fromisoformat(trial_ends_at)
1067
+ else:
1068
+ ends = trial_ends_at
1069
+ if ends.tzinfo is None:
1070
+ ends = ends.replace(tzinfo=timezone.utc)
1071
+ delta = (ends - datetime.now(timezone.utc)).days
1072
+ trial_countdown_days = max(0, delta)
1073
+ except Exception: # noqa: BLE001
1074
+ pass
1075
+
1076
+ return JSONResponse(content={
1077
+ "plan": {
1078
+ "tier": plan_tier,
1079
+ "display_name": plan_obj.display_name,
1080
+ "badge_label": plan_obj.display_name.upper(),
1081
+ },
1082
+ "usage": {
1083
+ "functional_scans": {
1084
+ "used": func_used,
1085
+ "quota": monthly_scan_quota,
1086
+ "percent": func_pct,
1087
+ "approaching_limit": func_pct >= 0.8,
1088
+ },
1089
+ "security_scans": {
1090
+ "used": sec_used,
1091
+ "quota": monthly_security_scan_quota,
1092
+ "percent": sec_pct,
1093
+ "approaching_limit": sec_pct >= 0.8,
1094
+ },
1095
+ },
1096
+ "upgrade": {
1097
+ "show_cta": show_upgrade_cta,
1098
+ "upgrade_tier": upgrade_tier if show_upgrade_cta else None,
1099
+ "upgrade_url": upgrade_url,
1100
+ },
1101
+ "trial": {
1102
+ "is_trial": trial_countdown_days is not None,
1103
+ "days_remaining": trial_countdown_days,
1104
+ },
1105
+ "demo_mode": {
1106
+ "active": is_demo,
1107
+ "banner_message": "🎯 Demo Environment — data is synthetic and resets periodically" if is_demo else None,
1108
+ },
1109
+ "billing": {
1110
+ "enabled": billing_enabled,
1111
+ },
1112
+ })
1113
+
1114
+
1115
+ # ---------------------------------------------------------------------------
1116
+ # Discovery / Scenario Suggestion endpoints (Phase 3)
1117
+ # ---------------------------------------------------------------------------
1118
+
1119
+
1120
+ def _assign_confidence(scenario: Dict[str, Any]) -> float:
1121
+ """Heuristic confidence score based on scenario type."""
1122
+ type_confidence = {
1123
+ "happy_path": 0.9,
1124
+ "flow": 0.8,
1125
+ "boundary": 0.6,
1126
+ "negative": 0.5,
1127
+ "exploratory": 0.4,
1128
+ }
1129
+ scenario_type = scenario.get("metadata", {}).get("scenario_type", "exploratory")
1130
+ return type_confidence.get(scenario_type, 0.5)
1131
+
1132
+
1133
+ def _assign_risk_score(scenario: Dict[str, Any]) -> float:
1134
+ """Heuristic risk score based on scenario type and step count."""
1135
+ type_risk = {
1136
+ "happy_path": 0.3,
1137
+ "flow": 0.5,
1138
+ "boundary": 0.7,
1139
+ "negative": 0.8,
1140
+ "exploratory": 0.4,
1141
+ }
1142
+ scenario_type = scenario.get("metadata", {}).get("scenario_type", "exploratory")
1143
+ return type_risk.get(scenario_type, 0.5)
1144
+
1145
+
1146
+ async def _run_discovery(discovery_id: str, req: DiscoveryRequest) -> None:
1147
+ """Background task: crawl target URL and generate scenario suggestions."""
1148
+ from mannf.product.dashboard.telemetry import ( # noqa: PLC0415
1149
+ broadcast_discovery_complete,
1150
+ broadcast_discovery_failed,
1151
+ broadcast_discovery_progress,
1152
+ )
1153
+
1154
+ result = _discoveries[discovery_id]
1155
+ try:
1156
+ # Import Phase 1 / Phase 2 components — gracefully degrade when
1157
+ # Playwright or the crawler aren't installed.
1158
+ try:
1159
+ from mannf.core.agents.web_crawler_agent import WebCrawlerAgent # noqa: PLC0415
1160
+ from mannf.core.browser.scenario_generator import ScenarioConfigGenerator # noqa: PLC0415
1161
+ from mannf.core.messaging.bus import MessageBus # noqa: PLC0415
1162
+ except ImportError as exc:
1163
+ result.status = "failed"
1164
+ result.error = (
1165
+ f"Required components not available (Playwright may not be installed): {exc}"
1166
+ )
1167
+ await broadcast_discovery_failed(discovery_id, result.error)
1168
+ return
1169
+
1170
+ bus = MessageBus()
1171
+ crawler = WebCrawlerAgent(
1172
+ agent_id=f"crawler-{discovery_id[:8]}",
1173
+ bus=bus,
1174
+ service_names=[req.url],
1175
+ base_url=req.url,
1176
+ headless=True,
1177
+ max_pages=req.max_pages,
1178
+ max_depth=req.max_depth,
1179
+ )
1180
+
1181
+ # Broadcast initial progress
1182
+ await broadcast_discovery_progress(
1183
+ discovery_id=discovery_id,
1184
+ pages_visited=0,
1185
+ total_pages=req.max_pages,
1186
+ forms_found=0,
1187
+ )
1188
+
1189
+ await crawler.start_browser()
1190
+ try:
1191
+ await crawler._run_crawl()
1192
+ finally:
1193
+ await crawler.stop_browser()
1194
+
1195
+ discovery_model = crawler._discovery_model
1196
+ if discovery_model is None:
1197
+ result.status = "failed"
1198
+ result.error = "Crawler produced no discovery model"
1199
+ await broadcast_discovery_failed(discovery_id, result.error)
1200
+ return
1201
+
1202
+ # Update stats
1203
+ pages_count = len(discovery_model.pages)
1204
+ forms_count = sum(len(p.forms) for p in discovery_model.pages)
1205
+ edges_count = len(discovery_model.edges)
1206
+
1207
+ await broadcast_discovery_progress(
1208
+ discovery_id=discovery_id,
1209
+ pages_visited=pages_count,
1210
+ total_pages=req.max_pages,
1211
+ forms_found=forms_count,
1212
+ )
1213
+
1214
+ # Generate scenarios via Phase 2
1215
+ llm_provider = None
1216
+ if req.llm_provider:
1217
+ try:
1218
+ from mannf.product.llm.config import LLMConfig # noqa: PLC0415
1219
+ from mannf.product.llm.factory import get_provider # noqa: PLC0415
1220
+
1221
+ llm_cfg = LLMConfig(
1222
+ provider=req.llm_provider,
1223
+ api_key=req.llm_api_key,
1224
+ model=req.llm_model or "",
1225
+ )
1226
+ llm_provider = get_provider(llm_cfg)
1227
+ except Exception as llm_exc: # noqa: BLE001
1228
+ logger.warning("Failed to create LLM provider for discovery: %s", llm_exc)
1229
+
1230
+ generator = ScenarioConfigGenerator(
1231
+ discovery_model,
1232
+ include_negative=req.include_negative,
1233
+ include_boundary=req.include_boundary,
1234
+ llm_provider=llm_provider,
1235
+ )
1236
+ if llm_provider is not None and llm_provider.is_available():
1237
+ raw_scenarios = await generator.generate_all_async()
1238
+ else:
1239
+ raw_scenarios = generator.generate_all()
1240
+
1241
+ scenarios = [
1242
+ DiscoveryScenario(
1243
+ scenario_id=str(uuid.uuid4()),
1244
+ url=s.get("url", req.url),
1245
+ steps=s.get("steps", []),
1246
+ metadata=s.get("metadata", {}),
1247
+ selected=True,
1248
+ confidence=_assign_confidence(s),
1249
+ risk_score=_assign_risk_score(s),
1250
+ )
1251
+ for s in raw_scenarios
1252
+ ]
1253
+
1254
+ result.scenarios = scenarios
1255
+ result.stats = {
1256
+ "pages_discovered": pages_count,
1257
+ "forms_found": forms_count,
1258
+ "flows_detected": edges_count,
1259
+ "total_scenarios": len(scenarios),
1260
+ }
1261
+ result.status = "complete"
1262
+ await broadcast_discovery_complete(discovery_id, len(scenarios))
1263
+ await _persist_discovery(discovery_id)
1264
+
1265
+ except Exception as exc: # noqa: BLE001
1266
+ logger.exception("Discovery %s failed", discovery_id)
1267
+ result.status = "failed"
1268
+ result.error = str(exc)
1269
+ await broadcast_discovery_failed(discovery_id, str(exc))
1270
+ await _persist_discovery(discovery_id)
1271
+
1272
+
1273
+ @router.post("/api/v1/discover")
1274
+ async def start_discovery(body: DiscoveryRequest) -> Dict[str, Any]:
1275
+ """Trigger a discovery crawl and scenario generation.
1276
+
1277
+ Returns immediately with a ``discovery_id``; progress is broadcast via
1278
+ the ``/ws/live`` WebSocket connection.
1279
+ """
1280
+ discovery_id = str(uuid.uuid4())
1281
+ result = DiscoveryResult(
1282
+ discovery_id=discovery_id,
1283
+ status="running",
1284
+ base_url=body.url,
1285
+ scenarios=[],
1286
+ stats={},
1287
+ created_at=datetime.now(timezone.utc).isoformat(),
1288
+ )
1289
+ _discoveries[discovery_id] = result
1290
+ asyncio.create_task(_persist_discovery(discovery_id))
1291
+ asyncio.create_task(_run_discovery(discovery_id, body))
1292
+ return {"discovery_id": discovery_id, "status": "running"}
1293
+
1294
+
1295
+ @router.post("/api/v1/discover/import")
1296
+ async def import_artifact(
1297
+ file: Optional[UploadFile] = File(None),
1298
+ content: Optional[str] = Form(None),
1299
+ format: Optional[str] = Form(None),
1300
+ base_url: Optional[str] = Form(None),
1301
+ ) -> Dict[str, Any]:
1302
+ """Import an artifact (HAR, Postman, Playwright, cURL, etc.) as a discovery.
1303
+
1304
+ Accepts either:
1305
+
1306
+ * A **file upload** (multipart form data) — ``file`` field.
1307
+ * **Raw content** passed as a form field — ``content`` field (optionally
1308
+ with ``format`` to help format detection).
1309
+
1310
+ The format is auto-detected via
1311
+ :func:`~mannf.product.ingestors.loader.get_ingestor_for_source` when not
1312
+ supplied. The imported artifact is converted into a
1313
+ :class:`~mannf.product.dashboard.models.DiscoveryResult` via the
1314
+ :class:`~mannf.core.browser.ingestor_bridge.IngestorBridge` and stored in
1315
+ ``_discoveries`` so the standard ``GET /api/v1/discover/{id}`` endpoints
1316
+ work without modification.
1317
+
1318
+ Returns a ``discovery_id`` that can be polled with the existing
1319
+ ``/api/v1/discover/{id}`` endpoints.
1320
+ """
1321
+ from mannf.product.ingestors.loader import ( # noqa: PLC0415
1322
+ get_ingestor_for_source,
1323
+ load_all_ingestors,
1324
+ )
1325
+ from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
1326
+ from mannf.core.browser.ingestor_bridge import IngestorBridge # noqa: PLC0415
1327
+ from mannf.product.dashboard.telemetry import ( # noqa: PLC0415
1328
+ broadcast_discovery_complete,
1329
+ )
1330
+
1331
+ # ---- 1. Resolve raw content ----------------------------------------
1332
+ raw_content: Optional[str] = None
1333
+ filename: str = "import"
1334
+
1335
+ if file is not None:
1336
+ raw_bytes = await file.read()
1337
+ try:
1338
+ raw_content = raw_bytes.decode("utf-8")
1339
+ except UnicodeDecodeError:
1340
+ raw_content = raw_bytes.decode("latin-1")
1341
+ filename = file.filename or "import"
1342
+ elif content is not None:
1343
+ raw_content = content
1344
+ else:
1345
+ raise HTTPException(
1346
+ status_code=422,
1347
+ detail="Provide either a 'file' upload or a 'content' form field.",
1348
+ )
1349
+
1350
+ if not raw_content or not raw_content.strip():
1351
+ raise HTTPException(status_code=422, detail="Artifact content is empty.")
1352
+
1353
+ # ---- 2. Build IngestorSource and auto-detect format ----------------
1354
+ fmt = (format or "").strip().lower() or None
1355
+ source = IngestorSource(
1356
+ format=fmt or "auto",
1357
+ raw_content=raw_content,
1358
+ path=filename,
1359
+ metadata={"filename": filename},
1360
+ )
1361
+
1362
+ # Load all built-ins so get_ingestor_for_source has access to them
1363
+ all_ingestors = load_all_ingestors()
1364
+ ingestor = get_ingestor_for_source(source, all_ingestors)
1365
+
1366
+ if ingestor is None:
1367
+ raise HTTPException(
1368
+ status_code=422,
1369
+ detail=(
1370
+ f"Could not detect format for artifact '{filename}'. "
1371
+ "Supply a 'format' field (e.g. 'har', 'postman', 'playwright', 'curl')."
1372
+ ),
1373
+ )
1374
+
1375
+ # ---- 3. Run the ingestor -------------------------------------------
1376
+ try:
1377
+ ingest_result = await ingestor.ingest(source, config={})
1378
+ except Exception as exc: # noqa: BLE001
1379
+ logger.exception("Ingestor %r failed for import", ingestor.name)
1380
+ raise HTTPException(
1381
+ status_code=422,
1382
+ detail=f"Ingestor '{ingestor.name}' failed: {exc}",
1383
+ ) from exc
1384
+
1385
+ if not ingest_result.success and not ingest_result.test_cases and not ingest_result.endpoints:
1386
+ errors = "; ".join(ingest_result.errors) if ingest_result.errors else "unknown error"
1387
+ raise HTTPException(
1388
+ status_code=422,
1389
+ detail=f"Ingestion produced no results: {errors}",
1390
+ )
1391
+
1392
+ # ---- 4. Convert via IngestorBridge → scenario dicts ----------------
1393
+ effective_base_url = (base_url or "").strip() or "https://example.com"
1394
+ bridge = IngestorBridge(
1395
+ base_url=effective_base_url,
1396
+ source_tag=ingestor.name,
1397
+ )
1398
+ raw_scenarios = bridge.convert(ingest_result)
1399
+
1400
+ # ---- 5. Build DiscoveryResult and store ----------------------------
1401
+ discovery_id = str(uuid.uuid4())
1402
+ scenarios = [
1403
+ DiscoveryScenario(
1404
+ scenario_id=str(uuid.uuid4()),
1405
+ url=s.get("url", effective_base_url),
1406
+ steps=s.get("steps", []),
1407
+ metadata=s.get("metadata", {}),
1408
+ selected=True,
1409
+ confidence=_assign_confidence(s),
1410
+ risk_score=_assign_risk_score(s),
1411
+ )
1412
+ for s in raw_scenarios
1413
+ ]
1414
+
1415
+ result = DiscoveryResult(
1416
+ discovery_id=discovery_id,
1417
+ status="complete",
1418
+ base_url=effective_base_url,
1419
+ scenarios=scenarios,
1420
+ stats={
1421
+ "pages_discovered": 0,
1422
+ "forms_found": 0,
1423
+ "flows_detected": 0,
1424
+ "total_scenarios": len(scenarios),
1425
+ "endpoints_ingested": len(ingest_result.endpoints),
1426
+ "test_cases_ingested": len(ingest_result.test_cases),
1427
+ },
1428
+ created_at=datetime.now(timezone.utc).isoformat(),
1429
+ )
1430
+ _discoveries[discovery_id] = result
1431
+
1432
+ # ---- 6. Broadcast completion and persist ----------------------------------------
1433
+ await broadcast_discovery_complete(discovery_id, len(scenarios))
1434
+ asyncio.create_task(_persist_discovery(discovery_id))
1435
+
1436
+ return {
1437
+ "discovery_id": discovery_id,
1438
+ "status": "complete",
1439
+ "ingestor": ingestor.name,
1440
+ "scenarios_imported": len(scenarios),
1441
+ "scenario_preview": [
1442
+ {
1443
+ "scenario_id": s.scenario_id,
1444
+ "url": s.url,
1445
+ "description": s.metadata.get("description", ""),
1446
+ "scenario_type": s.metadata.get("scenario_type", ""),
1447
+ "has_auth": s.metadata.get("has_auth", False),
1448
+ "nav_flow": s.metadata.get("nav_flow", []),
1449
+ "assertions_count": len(s.metadata.get("assertions", [])),
1450
+ "steps_count": len(s.steps),
1451
+ "confidence": s.confidence,
1452
+ "risk_score": s.risk_score,
1453
+ }
1454
+ for s in scenarios[:5]
1455
+ ],
1456
+ }
1457
+
1458
+
1459
+ @router.post("/api/v1/discover/import/run")
1460
+ async def import_and_run(
1461
+ file: Optional[UploadFile] = File(None),
1462
+ content: Optional[str] = Form(None),
1463
+ format: Optional[str] = Form(None),
1464
+ base_url: Optional[str] = Form(None),
1465
+ target_url: Optional[str] = Form(None),
1466
+ max_iterations: int = Form(3),
1467
+ strategy: str = Form("all"),
1468
+ ) -> Dict[str, Any]:
1469
+ """Import an artifact and immediately launch an autonomous run seeded with the imported scenarios.
1470
+
1471
+ This is the one-click "Run autonomous with these seeds" trigger. It:
1472
+
1473
+ 1. Ingests the artifact (same as ``POST /api/v1/discover/import``).
1474
+ 2. Converts the ingested scenarios via :class:`~mannf.core.browser.ingestor_bridge.IngestorBridge`.
1475
+ 3. Starts an :class:`~mannf.core.agents.autonomous_loop_agent.AutonomousTestLoopAgent`
1476
+ with the converted scenarios pre-loaded as ``seeded_scenarios``.
1477
+ 4. Stores the agent's run ID and the seeded discovery in ``_autonomous_runs`` /
1478
+ ``_discoveries`` so they can be polled via the standard endpoints.
1479
+
1480
+ Returns a ``run_id`` and ``discovery_id`` that can be polled for progress.
1481
+ """
1482
+ from mannf.product.ingestors.loader import ( # noqa: PLC0415
1483
+ get_ingestor_for_source,
1484
+ load_all_ingestors,
1485
+ )
1486
+ from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
1487
+ from mannf.core.browser.ingestor_bridge import IngestorBridge # noqa: PLC0415
1488
+ from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
1489
+ from mannf.product.dashboard.telemetry import broadcast_discovery_complete # noqa: PLC0415
1490
+
1491
+ # ---- 1. Resolve content (reuse import logic) -------------------------
1492
+ raw_content: Optional[str] = None
1493
+ filename: str = "import"
1494
+
1495
+ if file is not None:
1496
+ raw_bytes = await file.read()
1497
+ try:
1498
+ raw_content = raw_bytes.decode("utf-8")
1499
+ except UnicodeDecodeError:
1500
+ raw_content = raw_bytes.decode("latin-1")
1501
+ filename = file.filename or "import"
1502
+ elif content is not None:
1503
+ raw_content = content
1504
+ else:
1505
+ raise HTTPException(
1506
+ status_code=422,
1507
+ detail="Provide either a 'file' upload or a 'content' form field.",
1508
+ )
1509
+
1510
+ if not raw_content or not raw_content.strip():
1511
+ raise HTTPException(status_code=422, detail="Artifact content is empty.")
1512
+
1513
+ # ---- 2. Ingest -------------------------------------------------------
1514
+ fmt = (format or "").strip().lower() or None
1515
+ source = IngestorSource(
1516
+ format=fmt or "auto",
1517
+ raw_content=raw_content,
1518
+ path=filename,
1519
+ metadata={"filename": filename},
1520
+ )
1521
+
1522
+ all_ingestors = load_all_ingestors()
1523
+ ingestor = get_ingestor_for_source(source, all_ingestors)
1524
+
1525
+ if ingestor is None:
1526
+ raise HTTPException(
1527
+ status_code=422,
1528
+ detail=(
1529
+ f"Could not detect format for artifact '{filename}'. "
1530
+ "Supply a 'format' field."
1531
+ ),
1532
+ )
1533
+
1534
+ try:
1535
+ ingest_result = await ingestor.ingest(source, config={})
1536
+ except Exception as exc: # noqa: BLE001
1537
+ logger.exception("Ingestor %r failed for import/run", ingestor.name)
1538
+ raise HTTPException(
1539
+ status_code=422,
1540
+ detail=f"Ingestor '{ingestor.name}' failed: {exc}",
1541
+ ) from exc
1542
+
1543
+ if not ingest_result.success and not ingest_result.test_cases and not ingest_result.endpoints:
1544
+ errors = "; ".join(ingest_result.errors) if ingest_result.errors else "unknown error"
1545
+ raise HTTPException(status_code=422, detail=f"Ingestion produced no results: {errors}")
1546
+
1547
+ # ---- 3. Convert → scenario dicts ------------------------------------
1548
+ effective_base_url = (base_url or target_url or "").strip() or "https://example.com"
1549
+ effective_target_url = (target_url or base_url or "").strip() or "https://example.com"
1550
+
1551
+ bridge = IngestorBridge(base_url=effective_base_url, source_tag=ingestor.name)
1552
+ raw_scenarios = bridge.convert(ingest_result)
1553
+
1554
+ # ---- 4. Store as a discovery ----------------------------------------
1555
+ discovery_id = str(uuid.uuid4())
1556
+ scenarios = [
1557
+ DiscoveryScenario(
1558
+ scenario_id=str(uuid.uuid4()),
1559
+ url=s.get("url", effective_base_url),
1560
+ steps=s.get("steps", []),
1561
+ metadata=s.get("metadata", {}),
1562
+ selected=True,
1563
+ confidence=_assign_confidence(s),
1564
+ risk_score=_assign_risk_score(s),
1565
+ )
1566
+ for s in raw_scenarios
1567
+ ]
1568
+
1569
+ discovery_result = DiscoveryResult(
1570
+ discovery_id=discovery_id,
1571
+ status="complete",
1572
+ base_url=effective_base_url,
1573
+ scenarios=scenarios,
1574
+ stats={
1575
+ "pages_discovered": 0,
1576
+ "forms_found": 0,
1577
+ "flows_detected": 0,
1578
+ "total_scenarios": len(scenarios),
1579
+ "endpoints_ingested": len(ingest_result.endpoints),
1580
+ "test_cases_ingested": len(ingest_result.test_cases),
1581
+ },
1582
+ created_at=datetime.now(timezone.utc).isoformat(),
1583
+ )
1584
+ _discoveries[discovery_id] = discovery_result
1585
+ await broadcast_discovery_complete(discovery_id, len(scenarios))
1586
+ asyncio.create_task(_persist_discovery(discovery_id))
1587
+
1588
+ # ---- 5. Spawn autonomous run with seeds -----------------------------
1589
+ agent = AutonomousTestLoopAgent(
1590
+ target_url=effective_target_url,
1591
+ max_iterations=max(1, min(max_iterations, 20)),
1592
+ strategy=strategy,
1593
+ seeded_scenarios=raw_scenarios,
1594
+ )
1595
+ run_id = agent.run_id
1596
+
1597
+ # Store a placeholder autonomous run record (dict-based, same as standard runs)
1598
+ _autonomous_runs[run_id] = {
1599
+ "run_id": run_id,
1600
+ "target_url": effective_target_url,
1601
+ "strategy": strategy,
1602
+ "status": "running",
1603
+ "started_at": datetime.now(timezone.utc).isoformat(),
1604
+ "total_iterations": 0,
1605
+ "total_scenarios_executed": 0,
1606
+ "total_passed": 0,
1607
+ "total_failed": 0,
1608
+ "total_flaky": 0,
1609
+ "iterations": [],
1610
+ "unique_failures": [],
1611
+ "coverage_summary": {},
1612
+ "seeded_scenarios_count": len(raw_scenarios),
1613
+ }
1614
+ asyncio.create_task(_persist_autonomous_run(run_id))
1615
+
1616
+ # Launch the autonomous loop in the background
1617
+ async def _run_seeded_agent() -> None:
1618
+ try:
1619
+ result = await agent.run_loop()
1620
+ _autonomous_runs[run_id].update(result.model_dump())
1621
+ _autonomous_runs[run_id]["status"] = result.status
1622
+ asyncio.create_task(_persist_autonomous_run(run_id))
1623
+ await _compute_and_dispatch_delta(run_id, result)
1624
+ except Exception: # noqa: BLE001
1625
+ logger.exception("Seeded autonomous run %s failed", run_id[:8])
1626
+ _autonomous_runs[run_id]["status"] = "failed"
1627
+ finally:
1628
+ _autonomous_runs[run_id].pop("_agent", None)
1629
+
1630
+ asyncio.create_task(_run_seeded_agent())
1631
+
1632
+ return {
1633
+ "run_id": run_id,
1634
+ "discovery_id": discovery_id,
1635
+ "status": "running",
1636
+ "ingestor": ingestor.name,
1637
+ "scenarios_seeded": len(raw_scenarios),
1638
+ "target_url": effective_target_url,
1639
+ }
1640
+
1641
+
1642
+ @router.get("/api/v1/discover/{discovery_id}")
1643
+ async def get_discovery(discovery_id: str) -> Dict[str, Any]:
1644
+ """Return the current status and scenarios for a discovery run."""
1645
+ result = _discoveries.get(discovery_id)
1646
+ if result is None:
1647
+ raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
1648
+ return result.model_dump()
1649
+
1650
+
1651
+ @router.put("/api/v1/discover/{discovery_id}/scenarios/{scenario_id}")
1652
+ async def update_scenario(
1653
+ discovery_id: str,
1654
+ scenario_id: str,
1655
+ body: Dict[str, Any],
1656
+ ) -> Dict[str, Any]:
1657
+ """Apply a partial update to a scenario (steps, values, selection state)."""
1658
+ result = _discoveries.get(discovery_id)
1659
+ if result is None:
1660
+ raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
1661
+ scenario = next((s for s in result.scenarios if s.scenario_id == scenario_id), None)
1662
+ if scenario is None:
1663
+ raise HTTPException(status_code=404, detail=f"Scenario '{scenario_id}' not found")
1664
+
1665
+ allowed_fields = {"url", "steps", "metadata", "selected", "confidence", "risk_score"}
1666
+ for field, value in body.items():
1667
+ if field in allowed_fields:
1668
+ setattr(scenario, field, value)
1669
+
1670
+ return scenario.model_dump()
1671
+
1672
+
1673
+ @router.delete("/api/v1/discover/{discovery_id}/scenarios/{scenario_id}")
1674
+ async def delete_scenario(discovery_id: str, scenario_id: str) -> Dict[str, Any]:
1675
+ """Remove a scenario from the suggestion list."""
1676
+ result = _discoveries.get(discovery_id)
1677
+ if result is None:
1678
+ raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
1679
+ original_count = len(result.scenarios)
1680
+ result.scenarios = [s for s in result.scenarios if s.scenario_id != scenario_id]
1681
+ if len(result.scenarios) == original_count:
1682
+ raise HTTPException(status_code=404, detail=f"Scenario '{scenario_id}' not found")
1683
+ return {"deleted": scenario_id}
1684
+
1685
+
1686
+ @router.post("/api/v1/discover/{discovery_id}/run")
1687
+ async def run_scenarios(
1688
+ discovery_id: str,
1689
+ body: Optional[Dict[str, Any]] = None,
1690
+ ) -> Dict[str, Any]:
1691
+ """Submit selected (or specified) scenarios to FunctionalTestOrchestrator."""
1692
+ result = _discoveries.get(discovery_id)
1693
+ if result is None:
1694
+ raise HTTPException(status_code=404, detail=f"Discovery '{discovery_id}' not found")
1695
+
1696
+ scenario_ids: Optional[List[str]] = (body or {}).get("scenario_ids")
1697
+ if scenario_ids is not None:
1698
+ selected = [s for s in result.scenarios if s.scenario_id in scenario_ids]
1699
+ else:
1700
+ selected = [s for s in result.scenarios if s.selected]
1701
+
1702
+ if not selected:
1703
+ raise HTTPException(status_code=422, detail="No scenarios selected for execution")
1704
+
1705
+ run_id = str(uuid.uuid4())
1706
+ tasks = [
1707
+ {"url": s.url, "steps": s.steps, "metadata": s.metadata}
1708
+ for s in selected
1709
+ ]
1710
+
1711
+ async def _run() -> None:
1712
+ try:
1713
+ from mannf.core.functional_orchestrator import FunctionalTestOrchestrator # noqa: PLC0415
1714
+ orch = FunctionalTestOrchestrator()
1715
+ await orch.run(tasks)
1716
+ except Exception: # noqa: BLE001
1717
+ logger.exception("Scenario run %s failed", run_id)
1718
+
1719
+ asyncio.create_task(_run())
1720
+ return {"run_id": run_id, "scenarios_submitted": len(selected)}
1721
+
1722
+
1723
+ @router.websocket("/ws/live")
1724
+ async def websocket_live(ws: WebSocket) -> None:
1725
+ """WebSocket endpoint for real-time dashboard updates during scans.
1726
+
1727
+ The client receives JSON messages with ``type`` and ``data`` fields:
1728
+
1729
+ - ``{"type": "ping"}`` — heartbeat every 5 s
1730
+ - ``{"type": "scan_update", "data": {...}}`` — scan progress
1731
+ - ``{"type": "anomaly", "data": {...}}`` — new anomaly detected
1732
+ - ``{"type": "summary", "data": {...}}`` — updated summary stats
1733
+ """
1734
+ await manager.connect(ws)
1735
+ try:
1736
+ # Send an initial ping so the client knows the connection is alive
1737
+ await ws.send_text(json.dumps({"type": "ping"}))
1738
+ while True:
1739
+ # Keep the connection alive with periodic pings; the client can
1740
+ # also send messages (currently ignored but allows future use).
1741
+ try:
1742
+ await asyncio.wait_for(ws.receive_text(), timeout=5.0)
1743
+ except asyncio.TimeoutError:
1744
+ await ws.send_text(json.dumps({"type": "ping"}))
1745
+ except WebSocketDisconnect:
1746
+ pass
1747
+ finally:
1748
+ manager.disconnect(ws)
1749
+
1750
+
1751
+ # ---------------------------------------------------------------------------
1752
+ # Autonomous Test Loop endpoints (Phase 5)
1753
+ # ---------------------------------------------------------------------------
1754
+
1755
+
1756
+ async def _run_autonomous(run_id: str, req: AutonomousRequest) -> None:
1757
+ """Background task: run the full autonomous test loop."""
1758
+ from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
1759
+
1760
+ report = _autonomous_runs[run_id]
1761
+
1762
+ # Build optional LLM provider
1763
+ llm_provider = None
1764
+ if req.llm_provider and req.llm_api_key:
1765
+ try:
1766
+ from mannf.product.llm.config import LLMConfig # noqa: PLC0415
1767
+ from mannf.product.llm.factory import get_provider # noqa: PLC0415
1768
+ config = LLMConfig(
1769
+ provider=req.llm_provider,
1770
+ model=req.llm_model or "",
1771
+ api_key=req.llm_api_key,
1772
+ )
1773
+ candidate = get_provider(config)
1774
+ if candidate.is_available():
1775
+ llm_provider = candidate
1776
+ except Exception: # noqa: BLE001
1777
+ logger.warning("Autonomous run %s: could not initialise LLM provider", run_id)
1778
+
1779
+ agent = AutonomousTestLoopAgent(
1780
+ target_url=req.url,
1781
+ max_iterations=req.max_iterations,
1782
+ max_duration_s=req.max_duration_s,
1783
+ strategy=req.strategy,
1784
+ risk_threshold=req.risk_threshold,
1785
+ max_pages=req.max_pages,
1786
+ max_depth=req.max_depth,
1787
+ headless=req.headless,
1788
+ llm_provider=llm_provider,
1789
+ )
1790
+
1791
+ # Store agent reference for stop support
1792
+ _autonomous_runs[run_id]["_agent"] = agent
1793
+
1794
+ try:
1795
+ result = await agent.run_loop()
1796
+ # Propagate attribution fields from request into the report
1797
+ if req.build_id:
1798
+ result.build_id = req.build_id
1799
+ if req.commit_sha:
1800
+ result.commit_sha = req.commit_sha
1801
+ if req.deploy_tag:
1802
+ result.deploy_tag = req.deploy_tag
1803
+ _autonomous_runs[run_id].update(result.model_dump())
1804
+ _autonomous_runs[run_id]["status"] = result.status
1805
+ if result.status == "paused":
1806
+ _autonomous_runs[run_id]["_pause_state"] = agent.get_pause_state()
1807
+ # Update flake detector with task outcomes from this run (Phase 6.5)
1808
+ _update_flake_detector_from_run(run_id, result)
1809
+ # Compute run-over-run diff and dispatch delta notifications
1810
+ await _compute_and_dispatch_delta(run_id, result)
1811
+ # File bug tickets for unique failures (fire-and-forget, non-blocking)
1812
+ asyncio.create_task(_auto_file_bugs(run_id, result))
1813
+ except Exception as exc: # noqa: BLE001
1814
+ logger.exception("Autonomous run %s failed", run_id)
1815
+ _autonomous_runs[run_id]["status"] = "failed"
1816
+ _autonomous_runs[run_id]["error"] = str(exc)
1817
+ finally:
1818
+ _autonomous_runs[run_id].pop("_agent", None)
1819
+ await _persist_autonomous_run(run_id)
1820
+
1821
+
1822
+ @router.post("/api/v1/autonomous/start")
1823
+ async def start_autonomous(body: AutonomousRequest) -> Dict[str, Any]:
1824
+ """Start an autonomous test loop run.
1825
+
1826
+ Returns immediately with a ``run_id``; progress is broadcast via
1827
+ the ``/ws/live`` WebSocket connection.
1828
+ """
1829
+ run_id = str(uuid.uuid4())
1830
+ _autonomous_runs[run_id] = {
1831
+ "run_id": run_id,
1832
+ "target_url": body.url,
1833
+ "strategy": body.strategy,
1834
+ "status": "running",
1835
+ "started_at": datetime.now(timezone.utc).isoformat(),
1836
+ "total_iterations": 0,
1837
+ "total_scenarios_executed": 0,
1838
+ "total_passed": 0,
1839
+ "total_failed": 0,
1840
+ "total_flaky": 0,
1841
+ "iterations": [],
1842
+ "unique_failures": [],
1843
+ "coverage_summary": {},
1844
+ # Deploy attribution (Phase 6.3)
1845
+ "build_id": body.build_id,
1846
+ "commit_sha": body.commit_sha,
1847
+ "deploy_tag": body.deploy_tag,
1848
+ }
1849
+ asyncio.create_task(_persist_autonomous_run(run_id))
1850
+ asyncio.create_task(_run_autonomous(run_id, body))
1851
+ return {"run_id": run_id, "status": "running"}
1852
+
1853
+
1854
+ @router.get("/api/v1/autonomous/history")
1855
+ async def list_autonomous_runs() -> Dict[str, Any]:
1856
+ """Return summaries of all past autonomous runs."""
1857
+ summaries = []
1858
+ for run_id, run in _autonomous_runs.items():
1859
+ summaries.append(
1860
+ {
1861
+ "run_id": run_id,
1862
+ "target_url": run.get("target_url", ""),
1863
+ "strategy": run.get("strategy", ""),
1864
+ "status": run.get("status", "unknown"),
1865
+ "started_at": run.get("started_at", ""),
1866
+ "completed_at": run.get("completed_at", ""),
1867
+ "total_iterations": run.get("total_iterations", 0),
1868
+ "total_passed": run.get("total_passed", 0),
1869
+ "total_failed": run.get("total_failed", 0),
1870
+ }
1871
+ )
1872
+ return {"runs": summaries}
1873
+
1874
+
1875
+ @router.get("/api/v1/autonomous/{run_id}")
1876
+ async def get_autonomous_run(run_id: str) -> Dict[str, Any]:
1877
+ """Return the current status and partial results of an autonomous run."""
1878
+ run = _autonomous_runs.get(run_id)
1879
+ if run is None:
1880
+ raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
1881
+ # Return a copy without the internal _agent reference
1882
+ return {k: v for k, v in run.items() if not k.startswith("_")}
1883
+
1884
+
1885
+ @router.post("/api/v1/autonomous/{run_id}/stop")
1886
+ async def stop_autonomous_run(run_id: str) -> Dict[str, Any]:
1887
+ """Gracefully stop a running autonomous loop."""
1888
+ run = _autonomous_runs.get(run_id)
1889
+ if run is None:
1890
+ raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
1891
+
1892
+ agent = run.get("_agent")
1893
+ if agent is not None:
1894
+ agent.request_stop()
1895
+ return {"run_id": run_id, "status": "stopping"}
1896
+
1897
+ return {"run_id": run_id, "status": run.get("status", "unknown")}
1898
+
1899
+
1900
+ @router.post("/api/v1/autonomous/{run_id}/pause")
1901
+ async def pause_autonomous_run(run_id: str) -> Dict[str, Any]:
1902
+ """Pause a running autonomous loop after the current iteration.
1903
+
1904
+ The run state is serialised so it can be resumed later via the
1905
+ ``POST /api/v1/autonomous/{run_id}/resume`` endpoint.
1906
+ """
1907
+ run = _autonomous_runs.get(run_id)
1908
+ if run is None:
1909
+ raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
1910
+
1911
+ agent = run.get("_agent")
1912
+ if agent is None:
1913
+ return {"run_id": run_id, "status": run.get("status", "unknown")}
1914
+
1915
+ agent.request_pause()
1916
+ return {"run_id": run_id, "status": "pausing"}
1917
+
1918
+
1919
+ @router.post("/api/v1/autonomous/{run_id}/resume")
1920
+ async def resume_autonomous_run(run_id: str) -> Dict[str, Any]:
1921
+ """Resume a previously paused autonomous run.
1922
+
1923
+ Reconstructs the agent from the saved pause state stored in the run
1924
+ record and restarts the loop from the paused iteration.
1925
+ """
1926
+ from mannf.core.agents.autonomous_loop_agent import AutonomousTestLoopAgent # noqa: PLC0415
1927
+
1928
+ run = _autonomous_runs.get(run_id)
1929
+ if run is None:
1930
+ raise HTTPException(status_code=404, detail=f"Autonomous run '{run_id}' not found")
1931
+
1932
+ if run.get("status") != "paused":
1933
+ raise HTTPException(
1934
+ status_code=422,
1935
+ detail=f"Autonomous run '{run_id}' is not paused (status={run.get('status')})",
1936
+ )
1937
+
1938
+ pause_state = run.get("_pause_state")
1939
+ if not pause_state:
1940
+ raise HTTPException(
1941
+ status_code=422,
1942
+ detail=f"No pause state available for run '{run_id}'",
1943
+ )
1944
+
1945
+ # Reconstruct and restart the agent
1946
+ agent = AutonomousTestLoopAgent.resume(pause_state)
1947
+ _autonomous_runs[run_id]["status"] = "running"
1948
+ _autonomous_runs[run_id]["_agent"] = agent
1949
+
1950
+ async def _continue() -> None:
1951
+ try:
1952
+ result = await agent.run_loop()
1953
+ _autonomous_runs[run_id].update(result.model_dump())
1954
+ _autonomous_runs[run_id]["status"] = result.status
1955
+ # Update flake detector with task outcomes (Phase 6.5)
1956
+ _update_flake_detector_from_run(run_id, result)
1957
+ await _persist_autonomous_run(run_id)
1958
+ except Exception as exc: # noqa: BLE001
1959
+ logger.exception("Resumed autonomous run %s failed", run_id)
1960
+ _autonomous_runs[run_id]["status"] = "failed"
1961
+ _autonomous_runs[run_id]["error"] = str(exc)
1962
+ await _persist_autonomous_run(run_id)
1963
+ finally:
1964
+ _autonomous_runs[run_id].pop("_agent", None)
1965
+ _autonomous_runs[run_id].pop("_pause_state", None)
1966
+
1967
+ asyncio.create_task(_continue())
1968
+ return {"run_id": run_id, "status": "running"}
1969
+
1970
+
1971
+ # ---------------------------------------------------------------------------
1972
+ # Delta diff helpers (Phase 6.3)
1973
+ # ---------------------------------------------------------------------------
1974
+
1975
+
1976
+ def _update_flake_detector_from_run(
1977
+ run_id: str,
1978
+ result: "AutonomousRunReport",
1979
+ ) -> None:
1980
+ """Feed autonomous run outcomes into the flake detector (Phase 6.5)."""
1981
+ try:
1982
+ for failure in result.unique_failures:
1983
+ page_key = failure.get("page", failure.get("url", ""))
1984
+ scenario_id = failure.get("task_id", "")
1985
+ if page_key and scenario_id:
1986
+ _flake_detector.record(page_key, scenario_id, passed=False)
1987
+
1988
+ # Also record pages that were covered without failure as passing
1989
+ cs = result.coverage_summary or {}
1990
+ covered_pages = cs.get("covered_pages", [])
1991
+ failed_pages = {f.get("page", "") for f in result.unique_failures}
1992
+ for page_key in covered_pages:
1993
+ if page_key not in failed_pages:
1994
+ # We don't have scenario-level pass data here; record as a
1995
+ # synthetic "run-level" pass for the page using run_id as scenario
1996
+ _flake_detector.record(page_key, run_id, passed=True)
1997
+ except Exception as exc: # noqa: BLE001
1998
+ logger.debug("_update_flake_detector_from_run failed (non-fatal): %s", exc)
1999
+
2000
+
2001
+
2002
+ def _find_previous_run(current_run_id: str, target_url: str) -> Optional[Dict[str, Any]]:
2003
+ """Return the most recent completed run for *target_url* other than *current_run_id*."""
2004
+ candidates = [
2005
+ run
2006
+ for rid, run in _autonomous_runs.items()
2007
+ if rid != current_run_id
2008
+ and run.get("target_url") == target_url
2009
+ and run.get("status") in {"complete", "stopped"}
2010
+ and not run.get("error")
2011
+ ]
2012
+ if not candidates:
2013
+ return None
2014
+ # Sort by started_at descending
2015
+ candidates.sort(key=lambda r: r.get("started_at", ""), reverse=True)
2016
+ return candidates[0]
2017
+
2018
+
2019
+ def _run_dict_to_report(run: Dict[str, Any]) -> "AutonomousRunReport":
2020
+ """Reconstruct an AutonomousRunReport from an in-memory run dict."""
2021
+ from mannf.core.agents.autonomous_loop_models import AutonomousRunReport # noqa: PLC0415
2022
+
2023
+ return AutonomousRunReport(
2024
+ run_id=run.get("run_id", ""),
2025
+ target_url=run.get("target_url", ""),
2026
+ strategy=run.get("strategy", "all"),
2027
+ total_iterations=run.get("total_iterations", 0),
2028
+ total_scenarios_executed=run.get("total_scenarios_executed", 0),
2029
+ total_passed=run.get("total_passed", 0),
2030
+ total_failed=run.get("total_failed", 0),
2031
+ total_flaky=run.get("total_flaky", 0),
2032
+ unique_failures=run.get("unique_failures", []),
2033
+ coverage_summary=run.get("coverage_summary", {}),
2034
+ iterations=run.get("iterations", []),
2035
+ started_at=run.get("started_at", ""),
2036
+ completed_at=run.get("completed_at", ""),
2037
+ duration_s=run.get("duration_s", 0.0),
2038
+ stop_reason=run.get("stop_reason", ""),
2039
+ status=run.get("status", ""),
2040
+ error=run.get("error", ""),
2041
+ build_id=run.get("build_id"),
2042
+ commit_sha=run.get("commit_sha"),
2043
+ deploy_tag=run.get("deploy_tag"),
2044
+ )
2045
+
2046
+
2047
+ async def _compute_and_dispatch_delta(
2048
+ run_id: str,
2049
+ result: "AutonomousRunReport",
2050
+ ) -> None:
2051
+ """Compute the run-over-run diff and dispatch delta notifications (best-effort)."""
2052
+ try:
2053
+ from mannf.core.agents.autonomous_run_differ import AutonomousRunDiffer # noqa: PLC0415
2054
+ from mannf.product.notifications.dispatcher import dispatch_scan_notification # noqa: PLC0415
2055
+ from mannf.product.dashboard.telemetry import broadcast_autonomous_complete # noqa: PLC0415
2056
+
2057
+ previous_run = _find_previous_run(run_id, result.target_url)
2058
+ if previous_run is None:
2059
+ return
2060
+
2061
+ baseline = _run_dict_to_report(previous_run)
2062
+ differ = AutonomousRunDiffer()
2063
+ run_diff = differ.diff(baseline, result)
2064
+
2065
+ # Store diff summary on the current run for retrieval
2066
+ _autonomous_runs[run_id]["_last_diff"] = {
2067
+ "baseline_run_id": run_diff.baseline_run_id,
2068
+ "current_run_id": run_diff.current_run_id,
2069
+ "baseline_commit": run_diff.baseline_commit,
2070
+ "current_commit": run_diff.current_commit,
2071
+ "summary": run_diff.summary,
2072
+ }
2073
+
2074
+ # Re-broadcast with delta enrichment
2075
+ await broadcast_autonomous_complete(
2076
+ run_id=run_id,
2077
+ stop_reason=result.stop_reason,
2078
+ total_iterations=result.total_iterations,
2079
+ total_passed=result.total_passed,
2080
+ total_failed=result.total_failed,
2081
+ delta=run_diff.summary,
2082
+ )
2083
+
2084
+ commit_label = result.commit_sha or result.build_id or "unknown"
2085
+ summary = run_diff.summary
2086
+
2087
+ # Dispatch regression notification if there are new failures
2088
+ if summary.get("new_failure_count", 0) > 0:
2089
+ await dispatch_scan_notification(
2090
+ "autonomous_run.regression",
2091
+ scan_id=run_id,
2092
+ details={
2093
+ "new_failures": summary["new_failure_count"],
2094
+ "commit": commit_label,
2095
+ "target_url": result.target_url,
2096
+ "total_failed": result.total_failed,
2097
+ },
2098
+ )
2099
+
2100
+ # Dispatch recovery notification if failures were resolved
2101
+ elif summary.get("resolved_failure_count", 0) > 0 and summary.get("new_failure_count", 0) == 0:
2102
+ await dispatch_scan_notification(
2103
+ "autonomous_run.recovery",
2104
+ scan_id=run_id,
2105
+ details={
2106
+ "resolved_failures": summary["resolved_failure_count"],
2107
+ "commit": commit_label,
2108
+ "target_url": result.target_url,
2109
+ "total_passed": result.total_passed,
2110
+ },
2111
+ )
2112
+
2113
+ # Dispatch flakiness spike notification
2114
+ if summary.get("flakiness_spike_pages", 0) > 0:
2115
+ await dispatch_scan_notification(
2116
+ "autonomous_run.flake_spike",
2117
+ scan_id=run_id,
2118
+ details={
2119
+ "flakiness_spike_pages": summary["flakiness_spike_pages"],
2120
+ "commit": commit_label,
2121
+ "target_url": result.target_url,
2122
+ },
2123
+ )
2124
+
2125
+ except Exception as exc: # noqa: BLE001
2126
+ logger.debug("Delta diff/alerting for run %s failed (non-fatal): %s", run_id, exc)
2127
+
2128
+
2129
+ # ---------------------------------------------------------------------------
2130
+ # Auto-bug-filing pipeline (Phase 6.4)
2131
+ # ---------------------------------------------------------------------------
2132
+
2133
+
2134
+ _SEVERITY_ORDER: dict[str, int] = {
2135
+ "critical": 0,
2136
+ "high": 1,
2137
+ "medium": 2,
2138
+ "low": 3,
2139
+ "info": 4,
2140
+ }
2141
+
2142
+
2143
+ async def _auto_file_bugs(run_id: str, result: Any) -> None:
2144
+ """Best-effort: file bug tickets for unique failures in *result*.
2145
+
2146
+ This runs as a background task and *never* raises — any error is logged
2147
+ and silently swallowed to ensure the autonomous loop is never delayed.
2148
+ """
2149
+ try:
2150
+ from mannf.product.exporters.finding_adapter import FunctionalFindingAdapter # noqa: PLC0415
2151
+ from mannf.product.exporters.dedup import ( # noqa: PLC0415
2152
+ make_failure_fingerprint,
2153
+ fingerprint_label,
2154
+ )
2155
+
2156
+ cfg = _bug_filing_config
2157
+ if not cfg.enabled:
2158
+ return
2159
+
2160
+ unique_failures = getattr(result, "unique_failures", []) or []
2161
+ if not unique_failures:
2162
+ return
2163
+
2164
+ target_url = getattr(result, "target_url", "")
2165
+ threshold_order = _SEVERITY_ORDER.get(cfg.severity_threshold.lower(), _SEVERITY_ORDER["high"])
2166
+
2167
+ for failure in unique_failures:
2168
+ try:
2169
+ finding = FunctionalFindingAdapter.from_unique_failure(
2170
+ failure, run_id=run_id, target_url=target_url
2171
+ )
2172
+ finding_order = _SEVERITY_ORDER.get(finding.severity.lower(), _SEVERITY_ORDER["info"])
2173
+ if finding_order > threshold_order:
2174
+ # Below threshold — skip
2175
+ continue
2176
+
2177
+ fingerprint = make_failure_fingerprint(failure)
2178
+ fp_label = fingerprint_label(fingerprint)
2179
+
2180
+ if not cfg.auto_file:
2181
+ # Queue for manual approval
2182
+ bug_id = str(uuid.uuid4())
2183
+ pending = PendingBugEntry(
2184
+ bug_id=bug_id,
2185
+ run_id=run_id,
2186
+ fingerprint=fingerprint,
2187
+ failure=failure,
2188
+ severity=finding.severity,
2189
+ queued_at=datetime.now(timezone.utc).isoformat(),
2190
+ )
2191
+ _pending_bugs[bug_id] = pending
2192
+ logger.info(
2193
+ "Auto-filing queued for approval: bug_id=%s fingerprint=%s",
2194
+ bug_id,
2195
+ fingerprint[:16],
2196
+ )
2197
+ continue
2198
+
2199
+ # Auto-file the ticket
2200
+ report = FunctionalFindingAdapter.make_report(
2201
+ run_id, target_url, len(unique_failures)
2202
+ )
2203
+ # Add fingerprint label so dedup can find it later
2204
+ exporter_cfg = dict(cfg.exporter_config)
2205
+ existing_labels = list(exporter_cfg.get("labels") or [])
2206
+ exporter_cfg["labels"] = list({*existing_labels, fp_label, "nat-auto-filed"})
2207
+
2208
+ was_duplicate = False
2209
+ external_id: Optional[str] = None
2210
+ external_url: Optional[str] = None
2211
+
2212
+ if cfg.deduplicate:
2213
+ # Check for existing ticket
2214
+ existing = await _find_existing_bug(fingerprint, cfg)
2215
+ if existing:
2216
+ was_duplicate = True
2217
+ external_id = existing.get("id")
2218
+ external_url = existing.get("url")
2219
+ # Add a new-occurrence comment
2220
+ await _add_duplicate_comment(
2221
+ existing, cfg, run_id, fingerprint, failure
2222
+ )
2223
+
2224
+ if not was_duplicate:
2225
+ result_obj = await _call_exporter(finding, report, cfg.target_system, exporter_cfg)
2226
+ if result_obj:
2227
+ external_id = result_obj.external_id
2228
+ external_url = result_obj.external_url
2229
+
2230
+ bug_id = str(uuid.uuid4())
2231
+ filed = FiledBugEntry(
2232
+ bug_id=bug_id,
2233
+ run_id=run_id,
2234
+ fingerprint=fingerprint,
2235
+ failure=failure,
2236
+ target_system=cfg.target_system,
2237
+ external_id=external_id,
2238
+ external_url=external_url,
2239
+ severity=finding.severity,
2240
+ filed_at=datetime.now(timezone.utc).isoformat(),
2241
+ was_duplicate=was_duplicate,
2242
+ )
2243
+ _filed_bugs[bug_id] = filed
2244
+ logger.info(
2245
+ "Auto-filed bug: bug_id=%s target=%s external_id=%s duplicate=%s",
2246
+ bug_id,
2247
+ cfg.target_system,
2248
+ external_id,
2249
+ was_duplicate,
2250
+ )
2251
+
2252
+ except Exception as inner_exc: # noqa: BLE001
2253
+ logger.warning(
2254
+ "Auto-filing failed for failure in run %s (non-fatal): %s",
2255
+ run_id,
2256
+ inner_exc,
2257
+ )
2258
+
2259
+ except Exception as exc: # noqa: BLE001
2260
+ logger.warning("_auto_file_bugs for run %s failed (non-fatal): %s", run_id, exc)
2261
+
2262
+
2263
+ async def _find_existing_bug(fingerprint: str, cfg: BugFilingConfig) -> Optional[Dict[str, Any]]:
2264
+ """Check the external system for an existing open ticket with this fingerprint."""
2265
+ try:
2266
+ from mannf.product.exporters.dedup import ( # noqa: PLC0415
2267
+ find_existing_jira_issue,
2268
+ find_existing_github_issue,
2269
+ )
2270
+
2271
+ if cfg.target_system == "jira":
2272
+ ec = cfg.exporter_config
2273
+ key = await find_existing_jira_issue(
2274
+ fingerprint,
2275
+ base_url=ec.get("base_url", ""),
2276
+ project_key=ec.get("project_key", ""),
2277
+ email=ec.get("email", ""),
2278
+ api_token=ec.get("api_token", ""),
2279
+ )
2280
+ if key:
2281
+ base_url = ec.get("base_url", "").rstrip("/")
2282
+ return {"id": key, "url": f"{base_url}/browse/{key}"}
2283
+
2284
+ elif cfg.target_system == "github":
2285
+ ec = cfg.exporter_config
2286
+ number = await find_existing_github_issue(
2287
+ fingerprint,
2288
+ token=ec.get("token", ""),
2289
+ repo=ec.get("repo", ""),
2290
+ )
2291
+ if number is not None:
2292
+ repo = ec.get("repo", "")
2293
+ return {
2294
+ "id": str(number),
2295
+ "url": f"https://github.com/{repo}/issues/{number}",
2296
+ }
2297
+ except Exception as exc: # noqa: BLE001
2298
+ logger.debug("Dedup check failed (non-fatal): %s", exc)
2299
+ return None
2300
+
2301
+
2302
+ async def _add_duplicate_comment(
2303
+ existing: Dict[str, Any],
2304
+ cfg: BugFilingConfig,
2305
+ run_id: str,
2306
+ fingerprint: str,
2307
+ failure: Dict[str, Any],
2308
+ ) -> None:
2309
+ """Append a new-occurrence comment to an existing ticket."""
2310
+ try:
2311
+ from mannf.product.exporters.dedup import ( # noqa: PLC0415
2312
+ add_jira_comment,
2313
+ add_github_comment,
2314
+ )
2315
+
2316
+ comment = (
2317
+ f"**New occurrence detected** (run `{run_id}`)\n\n"
2318
+ f"- Page: `{failure.get('page', '?')}`\n"
2319
+ f"- Error: {failure.get('error', '?')[:200]}\n"
2320
+ f"- Fingerprint: `{fingerprint[:16]}…`\n"
2321
+ )
2322
+ ec = cfg.exporter_config
2323
+
2324
+ if cfg.target_system == "jira":
2325
+ await add_jira_comment(
2326
+ existing["id"],
2327
+ comment,
2328
+ base_url=ec.get("base_url", ""),
2329
+ email=ec.get("email", ""),
2330
+ api_token=ec.get("api_token", ""),
2331
+ )
2332
+
2333
+ elif cfg.target_system == "github":
2334
+ await add_github_comment(
2335
+ int(existing["id"]),
2336
+ comment,
2337
+ token=ec.get("token", ""),
2338
+ repo=ec.get("repo", ""),
2339
+ )
2340
+ except Exception as exc: # noqa: BLE001
2341
+ logger.debug("Duplicate comment failed (non-fatal): %s", exc)
2342
+
2343
+
2344
+ async def _call_exporter(
2345
+ finding: Any,
2346
+ report: Any,
2347
+ target_system: str,
2348
+ exporter_config: dict,
2349
+ ) -> Optional[Any]:
2350
+ """Instantiate the correct exporter and call export_finding."""
2351
+ try:
2352
+ if target_system == "jira":
2353
+ from mannf.product.exporters.jira_exporter import JiraExporter # noqa: PLC0415
2354
+ return await JiraExporter().export_finding(finding, report, exporter_config)
2355
+
2356
+ elif target_system == "github":
2357
+ from mannf.product.exporters.github_exporter import GitHubIssuesExporter # noqa: PLC0415
2358
+ return await GitHubIssuesExporter().export_finding(finding, report, exporter_config)
2359
+
2360
+ elif target_system == "webhook":
2361
+ from mannf.product.exporters.webhook_exporter import WebhookExporter # noqa: PLC0415
2362
+ return await WebhookExporter().export_finding(finding, report, exporter_config)
2363
+
2364
+ else:
2365
+ logger.warning("Unknown target_system '%s' — skipping export", target_system)
2366
+ except Exception as exc: # noqa: BLE001
2367
+ logger.warning("_call_exporter(%s) failed (non-fatal): %s", target_system, exc)
2368
+ return None
2369
+
2370
+
2371
+ # ---------------------------------------------------------------------------
2372
+ # Bug filing REST API (Phase 6.4)
2373
+ # ---------------------------------------------------------------------------
2374
+
2375
+
2376
+ @router.get("/api/v1/bugs/config")
2377
+ async def get_bug_filing_config() -> Dict[str, Any]:
2378
+ """Return the current auto-bug-filing configuration."""
2379
+ return _bug_filing_config.model_dump()
2380
+
2381
+
2382
+ @router.put("/api/v1/bugs/config")
2383
+ async def update_bug_filing_config(body: BugFilingConfig) -> Dict[str, Any]:
2384
+ """Update the auto-bug-filing configuration."""
2385
+ global _bug_filing_config
2386
+ _bug_filing_config = body
2387
+ return _bug_filing_config.model_dump()
2388
+
2389
+
2390
+ @router.get("/api/v1/bugs/filed")
2391
+ async def list_filed_bugs() -> Dict[str, Any]:
2392
+ """Return all auto-filed bug tickets with external links."""
2393
+ sorted_bugs = sorted(_filed_bugs.values(), key=lambda b: b.filed_at, reverse=True)
2394
+ return {
2395
+ "total": len(_filed_bugs),
2396
+ "bugs": [b.model_dump() for b in sorted_bugs],
2397
+ }
2398
+
2399
+
2400
+ @router.get("/api/v1/bugs/pending")
2401
+ async def list_pending_bugs() -> Dict[str, Any]:
2402
+ """Return the queue of failures awaiting manual approval."""
2403
+ pending = [
2404
+ b.model_dump()
2405
+ for b in sorted(
2406
+ _pending_bugs.values(),
2407
+ key=lambda b: b.queued_at,
2408
+ reverse=True,
2409
+ )
2410
+ if b.status == "pending"
2411
+ ]
2412
+ return {"total": len(pending), "bugs": pending}
2413
+
2414
+
2415
+ @router.post("/api/v1/bugs/{bug_id}/approve")
2416
+ async def approve_pending_bug(bug_id: str) -> Dict[str, Any]:
2417
+ """Approve a pending bug and immediately file a ticket in the configured system.
2418
+
2419
+ Moves the entry from the pending queue to the filed list.
2420
+ """
2421
+ entry = _pending_bugs.get(bug_id)
2422
+ if entry is None:
2423
+ raise HTTPException(status_code=404, detail=f"Pending bug '{bug_id}' not found")
2424
+ if entry.status != "pending":
2425
+ raise HTTPException(
2426
+ status_code=409,
2427
+ detail=f"Bug '{bug_id}' has already been {entry.status}",
2428
+ )
2429
+
2430
+ entry.status = "approved"
2431
+
2432
+ # File the ticket now
2433
+ try:
2434
+ from mannf.product.exporters.finding_adapter import FunctionalFindingAdapter # noqa: PLC0415
2435
+ from mannf.product.exporters.dedup import fingerprint_label # noqa: PLC0415
2436
+
2437
+ cfg = _bug_filing_config
2438
+ finding = FunctionalFindingAdapter.from_unique_failure(
2439
+ entry.failure, run_id=entry.run_id
2440
+ )
2441
+ report = FunctionalFindingAdapter.make_report(
2442
+ entry.run_id, entry.failure.get("url", ""), 1
2443
+ )
2444
+ fp_label = fingerprint_label(entry.fingerprint)
2445
+ exporter_cfg = dict(cfg.exporter_config)
2446
+ existing_labels = list(exporter_cfg.get("labels") or [])
2447
+ exporter_cfg["labels"] = list({*existing_labels, fp_label, "nat-auto-filed"})
2448
+
2449
+ result_obj = await _call_exporter(finding, report, cfg.target_system, exporter_cfg)
2450
+
2451
+ filed = FiledBugEntry(
2452
+ bug_id=str(uuid.uuid4()),
2453
+ run_id=entry.run_id,
2454
+ fingerprint=entry.fingerprint,
2455
+ failure=entry.failure,
2456
+ target_system=cfg.target_system,
2457
+ external_id=result_obj.external_id if result_obj else None,
2458
+ external_url=result_obj.external_url if result_obj else None,
2459
+ severity=entry.severity,
2460
+ filed_at=datetime.now(timezone.utc).isoformat(),
2461
+ )
2462
+ _filed_bugs[filed.bug_id] = filed
2463
+
2464
+ return {
2465
+ "bug_id": bug_id,
2466
+ "status": "approved",
2467
+ "filed_bug_id": filed.bug_id,
2468
+ "external_id": filed.external_id,
2469
+ "external_url": filed.external_url,
2470
+ }
2471
+
2472
+ except Exception as exc: # noqa: BLE001
2473
+ logger.warning("approve_pending_bug %s: filing failed: %s", bug_id, exc)
2474
+ return {"bug_id": bug_id, "status": "approved", "error": "Filing failed — see server logs for details."}
2475
+
2476
+
2477
+ @router.post("/api/v1/bugs/{bug_id}/dismiss")
2478
+ async def dismiss_pending_bug(bug_id: str) -> Dict[str, Any]:
2479
+ """Dismiss a pending bug — no ticket will be filed."""
2480
+ entry = _pending_bugs.get(bug_id)
2481
+ if entry is None:
2482
+ raise HTTPException(status_code=404, detail=f"Pending bug '{bug_id}' not found")
2483
+ if entry.status != "pending":
2484
+ raise HTTPException(
2485
+ status_code=409,
2486
+ detail=f"Bug '{bug_id}' has already been {entry.status}",
2487
+ )
2488
+
2489
+ entry.status = "dismissed"
2490
+ return {"bug_id": bug_id, "status": "dismissed"}
2491
+
2492
+
2493
+ # ---------------------------------------------------------------------------
2494
+ # Analytics endpoints (Phase 6.3)
2495
+ # ---------------------------------------------------------------------------
2496
+
2497
+
2498
+ @router.get("/api/v1/analytics/trends")
2499
+ async def get_analytics_trends(
2500
+ page: Optional[str] = Query(None, description="Filter by page path (e.g. /checkout)"),
2501
+ window: str = Query("7d", description="Time window: 1d, 7d, 30d"),
2502
+ target_url: Optional[str] = Query(None, description="Filter by target URL"),
2503
+ ) -> Dict[str, Any]:
2504
+ """Return pass rate, failure rate, and flakiness trends over completed autonomous runs.
2505
+
2506
+ Query parameters
2507
+ ----------------
2508
+ page:
2509
+ Optional page path filter (e.g. ``/checkout``).
2510
+ window:
2511
+ Time window: ``1d``, ``7d`` (default), or ``30d``.
2512
+ target_url:
2513
+ Optional target URL to filter runs.
2514
+ """
2515
+ from datetime import timedelta # noqa: PLC0415
2516
+
2517
+ window_map = {"1d": 1, "7d": 7, "30d": 30}
2518
+ days = window_map.get(window, 7)
2519
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
2520
+
2521
+ data_points = []
2522
+ for run in sorted(
2523
+ _autonomous_runs.values(),
2524
+ key=lambda r: r.get("started_at", ""),
2525
+ ):
2526
+ if run.get("status") not in {"complete", "stopped"}:
2527
+ continue
2528
+ if target_url and run.get("target_url") != target_url:
2529
+ continue
2530
+
2531
+ started_at_raw = run.get("started_at", "")
2532
+ try:
2533
+ started_dt = datetime.fromisoformat(started_at_raw)
2534
+ if started_dt.tzinfo is None:
2535
+ from datetime import timezone as _tz # noqa: PLC0415
2536
+ started_dt = started_dt.replace(tzinfo=_tz.utc)
2537
+ except (ValueError, TypeError):
2538
+ continue
2539
+
2540
+ if started_dt < cutoff:
2541
+ continue
2542
+
2543
+ total_executed = run.get("total_scenarios_executed", 0)
2544
+ total_passed = run.get("total_passed", 0)
2545
+ total_failed = run.get("total_failed", 0)
2546
+ total_flaky = run.get("total_flaky", 0)
2547
+
2548
+ # Apply page filter if supplied
2549
+ if page:
2550
+ cs = run.get("coverage_summary") or {}
2551
+ covered = cs.get("covered_pages", [])
2552
+ if page not in covered:
2553
+ continue
2554
+
2555
+ pass_rate = round(total_passed / total_executed, 4) if total_executed > 0 else None
2556
+ fail_rate = round(total_failed / total_executed, 4) if total_executed > 0 else None
2557
+ flaky_rate = round(total_flaky / total_executed, 4) if total_executed > 0 else None
2558
+
2559
+ data_points.append(
2560
+ {
2561
+ "run_id": run.get("run_id", ""),
2562
+ "started_at": started_at_raw,
2563
+ "target_url": run.get("target_url", ""),
2564
+ "total_passed": total_passed,
2565
+ "total_failed": total_failed,
2566
+ "total_flaky": total_flaky,
2567
+ "total_scenarios_executed": total_executed,
2568
+ "pass_rate": pass_rate,
2569
+ "fail_rate": fail_rate,
2570
+ "flaky_rate": flaky_rate,
2571
+ "commit_sha": run.get("commit_sha"),
2572
+ "build_id": run.get("build_id"),
2573
+ "deploy_tag": run.get("deploy_tag"),
2574
+ }
2575
+ )
2576
+
2577
+ return {
2578
+ "window": window,
2579
+ "page_filter": page,
2580
+ "target_url_filter": target_url,
2581
+ "data_points": data_points,
2582
+ "count": len(data_points),
2583
+ }
2584
+
2585
+
2586
+ @router.get("/api/v1/analytics/coverage")
2587
+ async def get_analytics_coverage(
2588
+ run_id: Optional[str] = Query(None, description="Run ID to inspect coverage for"),
2589
+ ) -> Dict[str, Any]:
2590
+ """Return coverage breakdown for an autonomous run.
2591
+
2592
+ If ``run_id`` is omitted, returns coverage for the most recent completed run.
2593
+
2594
+ Coverage includes:
2595
+ - Pages covered
2596
+ - Forms tested
2597
+ - Flows executed
2598
+ - Security payloads attempted (if security scenarios were enabled)
2599
+ """
2600
+ if run_id:
2601
+ run = _autonomous_runs.get(run_id)
2602
+ if run is None:
2603
+ raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
2604
+ else:
2605
+ # Pick most recent completed run
2606
+ completed = [
2607
+ r
2608
+ for r in _autonomous_runs.values()
2609
+ if r.get("status") in {"complete", "stopped"}
2610
+ ]
2611
+ if not completed:
2612
+ raise HTTPException(status_code=404, detail="No completed autonomous runs found")
2613
+ completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
2614
+ run = completed[0]
2615
+
2616
+ cs = run.get("coverage_summary") or {}
2617
+
2618
+ return {
2619
+ "run_id": run.get("run_id", ""),
2620
+ "target_url": run.get("target_url", ""),
2621
+ "status": run.get("status", ""),
2622
+ "started_at": run.get("started_at", ""),
2623
+ "completed_at": run.get("completed_at", ""),
2624
+ "pages_covered": cs.get("covered_pages", []),
2625
+ "page_count": len(cs.get("covered_pages", [])),
2626
+ "forms_tested": cs.get("forms_tested", 0),
2627
+ "flows_executed": cs.get("flows_executed", 0),
2628
+ "security_fields_tested": cs.get("security_fields_tested", 0),
2629
+ "security_payloads_attempted": cs.get("security_payloads_attempted", 0),
2630
+ "security_exploits_found": cs.get("security_exploits_found", 0),
2631
+ "owasp_coverage": cs.get("owasp_coverage", []),
2632
+ "coverage_summary": cs,
2633
+ }
2634
+
2635
+
2636
+ @router.post("/api/v1/autonomous/diff")
2637
+ async def diff_autonomous_runs(body: Dict[str, Any]) -> Dict[str, Any]:
2638
+ """Compare two autonomous runs and return a structured diff.
2639
+
2640
+ Request body
2641
+ ------------
2642
+ ``baseline_run_id`` : str
2643
+ Run ID of the baseline (earlier) run.
2644
+ ``current_run_id`` : str
2645
+ Run ID of the current (later) run to compare against the baseline.
2646
+
2647
+ Returns
2648
+ -------
2649
+ Structured diff including new failures, resolved failures, per-page deltas,
2650
+ flakiness changes, and coverage delta.
2651
+ """
2652
+ from mannf.core.agents.autonomous_run_differ import AutonomousRunDiffer # noqa: PLC0415
2653
+ from dataclasses import asdict # noqa: PLC0415
2654
+
2655
+ baseline_id = body.get("baseline_run_id")
2656
+ current_id = body.get("current_run_id")
2657
+
2658
+ if not baseline_id or not current_id:
2659
+ raise HTTPException(
2660
+ status_code=422,
2661
+ detail="Both 'baseline_run_id' and 'current_run_id' are required",
2662
+ )
2663
+
2664
+ baseline_run = _autonomous_runs.get(baseline_id)
2665
+ if baseline_run is None:
2666
+ raise HTTPException(status_code=404, detail=f"Baseline run '{baseline_id}' not found")
2667
+
2668
+ current_run = _autonomous_runs.get(current_id)
2669
+ if current_run is None:
2670
+ raise HTTPException(status_code=404, detail=f"Current run '{current_id}' not found")
2671
+
2672
+ baseline_report = _run_dict_to_report(baseline_run)
2673
+ current_report = _run_dict_to_report(current_run)
2674
+
2675
+ differ = AutonomousRunDiffer()
2676
+ run_diff = differ.diff(baseline_report, current_report)
2677
+
2678
+ return {
2679
+ "baseline_run_id": run_diff.baseline_run_id,
2680
+ "current_run_id": run_diff.current_run_id,
2681
+ "baseline_commit": run_diff.baseline_commit,
2682
+ "current_commit": run_diff.current_commit,
2683
+ "baseline_build": run_diff.baseline_build,
2684
+ "current_build": run_diff.current_build,
2685
+ "summary": run_diff.summary,
2686
+ "new_failures": [asdict(f) for f in run_diff.new_failures],
2687
+ "resolved_failures": [asdict(f) for f in run_diff.resolved_failures],
2688
+ "page_deltas": [asdict(d) for d in run_diff.page_deltas],
2689
+ "flakiness_changes": [asdict(c) for c in run_diff.flakiness_changes],
2690
+ "coverage_delta": asdict(run_diff.coverage_delta),
2691
+ }
2692
+
2693
+
2694
+ async def _persist_autonomous_run(run_id: str) -> None:
2695
+ """Upsert the current in-memory autonomous run entry to PostgreSQL (no-op if no DB)."""
2696
+ try:
2697
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
2698
+
2699
+ if _build_engine() is None or _async_session_factory is None:
2700
+ return
2701
+
2702
+ from mannf.product.models import AutonomousRun # noqa: PLC0415
2703
+
2704
+ entry = _autonomous_runs.get(run_id)
2705
+ if entry is None:
2706
+ return
2707
+
2708
+ async with _async_session_factory() as session:
2709
+ obj = await session.get(AutonomousRun, uuid.UUID(run_id))
2710
+ if obj is None:
2711
+ obj = AutonomousRun(id=uuid.UUID(run_id))
2712
+ session.add(obj)
2713
+
2714
+ obj.status = str(entry.get("status", ""))
2715
+ obj.target_url = entry.get("target_url")
2716
+ obj.strategy = entry.get("strategy")
2717
+ # Store full run data minus internal keys
2718
+ obj.report = {k: v for k, v in entry.items() if not k.startswith("_")}
2719
+ obj.error = entry.get("error")
2720
+
2721
+ raw_created = entry.get("created_at")
2722
+ if raw_created:
2723
+ try:
2724
+ obj.created_at = datetime.fromisoformat(raw_created)
2725
+ except (ValueError, TypeError):
2726
+ pass
2727
+
2728
+ raw_completed = entry.get("completed_at")
2729
+ if raw_completed:
2730
+ try:
2731
+ obj.completed_at = datetime.fromisoformat(raw_completed)
2732
+ except (ValueError, TypeError):
2733
+ pass
2734
+
2735
+ raw_tid = entry.get("tenant_id")
2736
+ obj.tenant_id = uuid.UUID(raw_tid) if raw_tid else None
2737
+
2738
+ await session.commit()
2739
+ except Exception as exc: # noqa: BLE001
2740
+ logger.warning("DB persist_autonomous_run %s failed: %s", run_id, exc)
2741
+
2742
+
2743
+ async def _persist_discovery(discovery_id: str) -> None:
2744
+ """Upsert the current in-memory discovery result to PostgreSQL (no-op if no DB)."""
2745
+ try:
2746
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
2747
+
2748
+ if _build_engine() is None or _async_session_factory is None:
2749
+ return
2750
+
2751
+ from mannf.product.models import DiscoveryRun # noqa: PLC0415
2752
+
2753
+ result = _discoveries.get(discovery_id)
2754
+ if result is None:
2755
+ return
2756
+
2757
+ async with _async_session_factory() as session:
2758
+ obj = await session.get(DiscoveryRun, uuid.UUID(discovery_id))
2759
+ if obj is None:
2760
+ obj = DiscoveryRun(id=uuid.UUID(discovery_id))
2761
+ session.add(obj)
2762
+
2763
+ obj.status = result.status
2764
+ obj.base_url = result.base_url
2765
+ obj.scenarios = [s.model_dump() for s in result.scenarios]
2766
+ obj.stats = result.stats
2767
+ obj.error = result.error
2768
+
2769
+ raw_created = result.created_at
2770
+ if raw_created:
2771
+ try:
2772
+ obj.created_at = datetime.fromisoformat(raw_created)
2773
+ except (ValueError, TypeError):
2774
+ pass
2775
+
2776
+ await session.commit()
2777
+ except Exception as exc: # noqa: BLE001
2778
+ logger.warning("DB persist_discovery %s failed: %s", discovery_id, exc)
2779
+
2780
+
2781
+ async def _preload_autonomous_runs() -> None:
2782
+ """Pre-populate ``_autonomous_runs`` from PostgreSQL on startup."""
2783
+ try:
2784
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
2785
+
2786
+ if _build_engine() is None or _async_session_factory is None:
2787
+ return
2788
+
2789
+ from sqlalchemy import select, desc # noqa: PLC0415
2790
+ from mannf.product.models import AutonomousRun # noqa: PLC0415
2791
+
2792
+ async with _async_session_factory() as session:
2793
+ result = await session.execute(
2794
+ select(AutonomousRun)
2795
+ .order_by(desc(AutonomousRun.created_at))
2796
+ .limit(200)
2797
+ )
2798
+ count = 0
2799
+ for row in result.scalars():
2800
+ run_id = str(row.id)
2801
+ if run_id not in _autonomous_runs:
2802
+ entry = row.report or {}
2803
+ entry["run_id"] = run_id
2804
+ entry["status"] = row.status
2805
+ entry["target_url"] = row.target_url
2806
+ entry["strategy"] = row.strategy
2807
+ entry["error"] = row.error
2808
+ entry["created_at"] = (
2809
+ row.created_at.isoformat() if row.created_at else None
2810
+ )
2811
+ entry["completed_at"] = (
2812
+ row.completed_at.isoformat() if row.completed_at else None
2813
+ )
2814
+ _autonomous_runs[run_id] = entry
2815
+ count += 1
2816
+ logger.info("Preloaded %d autonomous runs from DB", count)
2817
+ except Exception as exc: # noqa: BLE001
2818
+ logger.warning("DB preload_autonomous_runs failed (continuing in-memory only): %s", exc)
2819
+
2820
+
2821
+ async def _preload_discoveries() -> None:
2822
+ """Pre-populate ``_discoveries`` from PostgreSQL on startup."""
2823
+ try:
2824
+ from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
2825
+
2826
+ if _build_engine() is None or _async_session_factory is None:
2827
+ return
2828
+
2829
+ from sqlalchemy import select, desc # noqa: PLC0415
2830
+ from mannf.product.models import DiscoveryRun # noqa: PLC0415
2831
+
2832
+ async with _async_session_factory() as session:
2833
+ result = await session.execute(
2834
+ select(DiscoveryRun)
2835
+ .order_by(desc(DiscoveryRun.created_at))
2836
+ .limit(200)
2837
+ )
2838
+ count = 0
2839
+ for row in result.scalars():
2840
+ discovery_id = str(row.id)
2841
+ if discovery_id not in _discoveries:
2842
+ raw_scenarios = row.scenarios or []
2843
+ scenarios = []
2844
+ for s in raw_scenarios:
2845
+ try:
2846
+ scenarios.append(DiscoveryScenario(**s))
2847
+ except Exception: # noqa: BLE001
2848
+ pass
2849
+ _discoveries[discovery_id] = DiscoveryResult(
2850
+ discovery_id=discovery_id,
2851
+ status=row.status,
2852
+ base_url=row.base_url or "",
2853
+ scenarios=scenarios,
2854
+ stats=row.stats or {},
2855
+ error=row.error,
2856
+ created_at=(
2857
+ row.created_at.isoformat() if row.created_at else ""
2858
+ ),
2859
+ )
2860
+ count += 1
2861
+ logger.info("Preloaded %d discovery runs from DB", count)
2862
+ except Exception as exc: # noqa: BLE001
2863
+ logger.warning("DB preload_discoveries failed (continuing in-memory only): %s", exc)
2864
+
2865
+
2866
+ # ---------------------------------------------------------------------------
2867
+ # ML/LLM Diagnostics analytics endpoints (Phase 6.5)
2868
+ # ---------------------------------------------------------------------------
2869
+
2870
+
2871
+ @router.get("/api/v1/analytics/beliefs", response_model=List[BeliefTrend])
2872
+ async def get_belief_trends(
2873
+ page: Optional[str] = Query(None, description="Filter by page path (e.g. /checkout)"),
2874
+ window: str = Query("30d", description="Time window: 1d, 7d, 30d"),
2875
+ run_id: Optional[str] = Query(None, description="Filter by specific run ID"),
2876
+ ) -> List[BeliefTrend]:
2877
+ """Return time-series fault_likelihood belief snapshots per page.
2878
+
2879
+ Query parameters
2880
+ ----------------
2881
+ page:
2882
+ Optional page path filter (e.g. ``/checkout``).
2883
+ window:
2884
+ Time window: ``1d``, ``7d`` (default), or ``30d``.
2885
+ run_id:
2886
+ When supplied, return snapshots from that run only.
2887
+ """
2888
+ from datetime import timedelta # noqa: PLC0415
2889
+
2890
+ window_map = {"1d": 1, "7d": 7, "30d": 30}
2891
+ days = window_map.get(window, 30)
2892
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
2893
+
2894
+ results: List[BeliefTrend] = []
2895
+
2896
+ runs_to_scan = (
2897
+ {run_id: _autonomous_runs[run_id]}
2898
+ if run_id and run_id in _autonomous_runs
2899
+ else _autonomous_runs
2900
+ )
2901
+
2902
+ for rid, run in runs_to_scan.items():
2903
+ snapshots = run.get("belief_snapshots") or []
2904
+ for snap in snapshots:
2905
+ ts_str = snap.get("timestamp", "")
2906
+ try:
2907
+ ts = datetime.fromisoformat(ts_str)
2908
+ if ts.tzinfo is None:
2909
+ ts = ts.replace(tzinfo=timezone.utc)
2910
+ if ts < cutoff:
2911
+ continue
2912
+ except (ValueError, TypeError):
2913
+ continue
2914
+
2915
+ iteration = snap.get("iteration", 0)
2916
+ beliefs = snap.get("beliefs") or {}
2917
+
2918
+ for page_key, fault_likelihood in beliefs.items():
2919
+ if page and page_key != page:
2920
+ continue
2921
+ results.append(
2922
+ BeliefTrend(
2923
+ run_id=rid,
2924
+ iteration=iteration,
2925
+ page_key=page_key,
2926
+ fault_likelihood=float(fault_likelihood),
2927
+ timestamp=ts_str,
2928
+ )
2929
+ )
2930
+
2931
+ # Sort by timestamp then iteration
2932
+ results.sort(key=lambda r: (r.timestamp, r.iteration))
2933
+ return results
2934
+
2935
+
2936
+ @router.get("/api/v1/analytics/flakiness", response_model=List[FlakinessSummary])
2937
+ async def get_flakiness_summary(
2938
+ page: Optional[str] = Query(None, description="Filter by page path"),
2939
+ flaky_only: bool = Query(False, description="Return only flaky scenarios"),
2940
+ ) -> List[FlakinessSummary]:
2941
+ """Return flakiness statistics per scenario.
2942
+
2943
+ Aggregates pass/fail history from all persisted autonomous runs using the
2944
+ in-process :class:`~mannf.core.diagnostics.flake_detector.FlakeDetector`.
2945
+
2946
+ Query parameters
2947
+ ----------------
2948
+ page:
2949
+ Optional page path filter.
2950
+ flaky_only:
2951
+ When ``True``, return only scenarios labelled flaky.
2952
+ """
2953
+ summaries = _flake_detector.all_summaries()
2954
+
2955
+ results: List[FlakinessSummary] = []
2956
+ for s in summaries:
2957
+ if page and s.page_key != page:
2958
+ continue
2959
+ if flaky_only and not s.is_flaky:
2960
+ continue
2961
+ results.append(
2962
+ FlakinessSummary(
2963
+ page_key=s.page_key,
2964
+ scenario_id=s.scenario_id,
2965
+ is_flaky=s.is_flaky,
2966
+ flake_rate=s.flake_rate,
2967
+ total_runs=s.total_runs,
2968
+ pass_count=s.pass_count,
2969
+ fail_count=s.fail_count,
2970
+ alternations=s.alternations,
2971
+ )
2972
+ )
2973
+
2974
+ # Sort by flake_rate descending so worst offenders come first
2975
+ results.sort(key=lambda s: s.flake_rate, reverse=True)
2976
+ return results
2977
+
2978
+
2979
+ @router.get("/api/v1/analytics/clusters", response_model=List[FailureCluster])
2980
+ async def get_failure_clusters(
2981
+ run_id: Optional[str] = Query(None, description="Run ID to cluster failures from"),
2982
+ threshold: float = Query(0.4, description="Cosine similarity threshold (0.0–1.0)"),
2983
+ ) -> List[FailureCluster]:
2984
+ """Cluster failures by error-message similarity using TF-IDF cosine distance.
2985
+
2986
+ Query parameters
2987
+ ----------------
2988
+ run_id:
2989
+ When supplied, only cluster failures from that run. Otherwise,
2990
+ aggregates failures from the 10 most recent completed runs.
2991
+ threshold:
2992
+ Cosine similarity threshold in (0, 1]. Higher values produce
2993
+ tighter (more homogeneous) clusters.
2994
+ """
2995
+ from mannf.core.diagnostics.failure_clusterer import FailureClusterer # noqa: PLC0415
2996
+
2997
+ failures: List[Dict[str, Any]] = []
2998
+
2999
+ if run_id:
3000
+ run = _autonomous_runs.get(run_id)
3001
+ if run is None:
3002
+ raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found")
3003
+ failures = list(run.get("unique_failures") or [])
3004
+ else:
3005
+ # Aggregate from the 10 most recent completed runs
3006
+ completed = [
3007
+ r
3008
+ for r in _autonomous_runs.values()
3009
+ if r.get("status") in {"complete", "stopped"}
3010
+ ]
3011
+ completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
3012
+ for run in completed[:10]:
3013
+ failures.extend(run.get("unique_failures") or [])
3014
+
3015
+ if not failures:
3016
+ return []
3017
+
3018
+ try:
3019
+ clusterer = FailureClusterer(threshold=threshold)
3020
+ except ValueError:
3021
+ raise HTTPException(status_code=422, detail="threshold must be in (0, 1]")
3022
+
3023
+ raw_clusters = clusterer.cluster(failures)
3024
+ return [
3025
+ FailureCluster(
3026
+ cluster_id=c.cluster_id,
3027
+ label=c.label,
3028
+ representative_error=c.representative_error,
3029
+ pages=c.pages,
3030
+ size=c.size,
3031
+ failures=c.failures,
3032
+ )
3033
+ for c in raw_clusters
3034
+ ]
3035
+
3036
+
3037
+ @router.get("/api/v1/analytics/predictions", response_model=List[PredictiveRiskScore])
3038
+ async def get_predictive_risk(
3039
+ target_url: Optional[str] = Query(None, description="Filter by target URL"),
3040
+ top_k: int = Query(10, description="Maximum number of pages to return", ge=1, le=100),
3041
+ ) -> List[PredictiveRiskScore]:
3042
+ """Return predicted failure probability per page for the next run.
3043
+
3044
+ Predictive score is computed from:
3045
+ - **Belief momentum** — rate of change in ``fault_likelihood`` across the
3046
+ last 5 belief snapshots for the page.
3047
+ - **Recent failure density** — fraction of failures on the page across the
3048
+ last 5 completed runs.
3049
+
3050
+ Query parameters
3051
+ ----------------
3052
+ target_url:
3053
+ Optional target URL to filter runs.
3054
+ top_k:
3055
+ Maximum number of pages to return (highest predicted risk first).
3056
+ """
3057
+ # Gather the most recent completed runs
3058
+ completed = [
3059
+ r
3060
+ for r in _autonomous_runs.values()
3061
+ if r.get("status") in {"complete", "stopped"}
3062
+ and (not target_url or r.get("target_url") == target_url)
3063
+ ]
3064
+ completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
3065
+ recent_runs = completed[:5]
3066
+
3067
+ if not recent_runs:
3068
+ return []
3069
+
3070
+ # Per-page: collect the last N fault_likelihood values from snapshots
3071
+ page_belief_history: Dict[str, List[float]] = {}
3072
+ # Per-page: failure density
3073
+ page_failure_counts: Dict[str, int] = {}
3074
+ total_failure_scenarios = 0
3075
+
3076
+ for run in recent_runs:
3077
+ snapshots = run.get("belief_snapshots") or []
3078
+ for snap in snapshots:
3079
+ beliefs = snap.get("beliefs") or {}
3080
+ for pk, fl in beliefs.items():
3081
+ page_belief_history.setdefault(pk, []).append(float(fl))
3082
+
3083
+ for failure in run.get("unique_failures") or []:
3084
+ pk = failure.get("page", failure.get("url", ""))
3085
+ if pk:
3086
+ page_failure_counts[pk] = page_failure_counts.get(pk, 0) + 1
3087
+ total_failure_scenarios += 1
3088
+
3089
+ all_pages = set(page_belief_history) | set(page_failure_counts)
3090
+ scores: List[PredictiveRiskScore] = []
3091
+
3092
+ for pk in all_pages:
3093
+ belief_vals = page_belief_history.get(pk, [])
3094
+ # Belief momentum: average delta between consecutive snapshots
3095
+ if len(belief_vals) >= 2:
3096
+ deltas = [belief_vals[i] - belief_vals[i - 1] for i in range(1, len(belief_vals))]
3097
+ belief_momentum = sum(deltas) / len(deltas)
3098
+ else:
3099
+ belief_momentum = 0.0
3100
+
3101
+ latest_belief = belief_vals[-1] if belief_vals else 0.5
3102
+
3103
+ failure_density = (
3104
+ page_failure_counts.get(pk, 0) / total_failure_scenarios
3105
+ if total_failure_scenarios > 0
3106
+ else 0.0
3107
+ )
3108
+
3109
+ # Combine signals: weighted average of latest belief, momentum push, and failure density
3110
+ raw_prob = (
3111
+ 0.5 * latest_belief
3112
+ + 0.3 * max(0.0, belief_momentum) # positive momentum = degradation
3113
+ + 0.2 * failure_density
3114
+ )
3115
+ predicted_prob = round(max(0.0, min(1.0, raw_prob)), 4)
3116
+
3117
+ # Confidence: how many data points we have
3118
+ n_obs = len(belief_vals) + page_failure_counts.get(pk, 0)
3119
+ confidence = round(min(0.95, n_obs / (n_obs + 5)), 4)
3120
+
3121
+ scores.append(
3122
+ PredictiveRiskScore(
3123
+ page_key=pk,
3124
+ predicted_failure_probability=predicted_prob,
3125
+ belief_momentum=round(belief_momentum, 4),
3126
+ recent_failure_density=round(failure_density, 4),
3127
+ confidence=confidence,
3128
+ )
3129
+ )
3130
+
3131
+ scores.sort(key=lambda s: s.predicted_failure_probability, reverse=True)
3132
+ return scores[:top_k]
3133
+
3134
+
3135
+ # ---------------------------------------------------------------------------
3136
+ # Phase 7.4 — Confidence Calibration & Self-Validation endpoints
3137
+ # ---------------------------------------------------------------------------
3138
+
3139
+
3140
+ @router.post("/api/v1/findings/{finding_id}/dismiss", response_model=DismissedFinding)
3141
+ async def dismiss_finding(
3142
+ finding_id: str,
3143
+ dismissed_by: str = "",
3144
+ run_id: str = "",
3145
+ reason: str = "",
3146
+ ) -> DismissedFinding:
3147
+ """Dismiss a finding as a false positive (FP) and recalibrate belief states.
3148
+
3149
+ When a finding is dismissed:
3150
+
3151
+ 1. A ``DismissedFinding`` record is stored in-memory (and would be
3152
+ persisted to the ``dismissed_findings`` table in production via
3153
+ Alembic migration 007).
3154
+ 2. ``evidence=0.0`` is injected into every ``BeliefState`` that has
3155
+ knowledge of the endpoint, recalibrating fault-likelihood downward.
3156
+
3157
+ Parameters
3158
+ ----------
3159
+ finding_id:
3160
+ Unique identifier for the finding (from ``unique_failures[].task_id``
3161
+ or ``browser_security_findings[].id``).
3162
+ dismissed_by:
3163
+ Optional identifier for who is dismissing (user name / API key).
3164
+ run_id:
3165
+ Optional autonomous run ID the finding originated from.
3166
+ reason:
3167
+ Optional free-text reason for dismissal.
3168
+ """
3169
+ if finding_id in _dismissed_findings:
3170
+ raise HTTPException(
3171
+ status_code=409,
3172
+ detail=f"Finding '{finding_id}' has already been dismissed.",
3173
+ )
3174
+
3175
+ now_iso = datetime.now(timezone.utc).isoformat()
3176
+
3177
+ # Resolve the endpoint key from the finding
3178
+ endpoint = ""
3179
+ raw_run = _autonomous_runs.get(run_id) if run_id else None
3180
+ if raw_run:
3181
+ for failure in raw_run.get("unique_failures", []):
3182
+ if failure.get("task_id") == finding_id or failure.get("id") == finding_id:
3183
+ endpoint = failure.get("page") or failure.get("url", "")
3184
+ break
3185
+ if not endpoint:
3186
+ for f in raw_run.get("browser_security_findings", []):
3187
+ if f.get("id") == finding_id:
3188
+ endpoint = f.get("endpoint", "")
3189
+ break
3190
+
3191
+ # Persist the dismissal record
3192
+ record = DismissedFinding(
3193
+ finding_id=finding_id,
3194
+ endpoint=endpoint,
3195
+ dismissed_by=dismissed_by or "user",
3196
+ dismissed_at=now_iso,
3197
+ run_id=run_id,
3198
+ reason=reason,
3199
+ )
3200
+ _dismissed_findings[finding_id] = record
3201
+
3202
+ # Propagate FP feedback: inject evidence=0.0 into all known belief states
3203
+ if endpoint:
3204
+ _propagate_fp_feedback(endpoint)
3205
+
3206
+ logger.info(
3207
+ "dismiss_finding: finding_id=%s endpoint=%r run_id=%s",
3208
+ finding_id, endpoint, run_id,
3209
+ )
3210
+ return record
3211
+
3212
+
3213
+ def _propagate_fp_feedback(endpoint: str) -> None:
3214
+ """Inject ``evidence=0.0`` into active autonomous-run belief states for *endpoint*.
3215
+
3216
+ Iterates over all in-progress autonomous runs and updates their attached
3217
+ belief state so that the FP signal propagates through to the next
3218
+ iteration's scenario generation via the AdaptiveController.
3219
+
3220
+ Parameters
3221
+ ----------
3222
+ endpoint:
3223
+ Endpoint key (page path or full key) for which a finding was dismissed.
3224
+ """
3225
+ # Running agents may store a reference to themselves in the _autonomous_runs
3226
+ # record under the ``"_agent"`` key (set during background task execution).
3227
+ # Fall back gracefully if no live agent reference is found.
3228
+ updated = 0
3229
+ for run_record in _autonomous_runs.values():
3230
+ if run_record.get("status") != "running":
3231
+ continue
3232
+ agent = run_record.get("_agent")
3233
+ if agent is None:
3234
+ continue
3235
+ bs = getattr(agent, "_belief_state", None)
3236
+ if bs is None:
3237
+ continue
3238
+ if endpoint in bs.fault_likelihood or endpoint in bs.confidence:
3239
+ bs.update(endpoint, 0.0)
3240
+ updated += 1
3241
+ if updated:
3242
+ logger.debug(
3243
+ "_propagate_fp_feedback: updated %d belief state(s) for endpoint %r",
3244
+ updated, endpoint,
3245
+ )
3246
+
3247
+
3248
+ @router.get("/api/v1/findings/dismissed", response_model=List[DismissedFinding])
3249
+ async def list_dismissed_findings(
3250
+ run_id: Optional[str] = Query(None, description="Filter by run ID"),
3251
+ endpoint: Optional[str] = Query(None, description="Filter by endpoint path"),
3252
+ ) -> List[DismissedFinding]:
3253
+ """Return the list of findings dismissed as false positives.
3254
+
3255
+ Query parameters
3256
+ ----------------
3257
+ run_id:
3258
+ Optional filter — only return dismissals from the specified run.
3259
+ endpoint:
3260
+ Optional filter — only return dismissals for the specified endpoint.
3261
+ """
3262
+ results = list(_dismissed_findings.values())
3263
+ if run_id:
3264
+ results = [r for r in results if r.run_id == run_id]
3265
+ if endpoint:
3266
+ results = [r for r in results if r.endpoint == endpoint]
3267
+ results.sort(key=lambda r: r.dismissed_at, reverse=True)
3268
+ return results
3269
+
3270
+
3271
+ # ---------------------------------------------------------------------------
3272
+ # Finding annotations (comments) — Phase 10.4
3273
+ # ---------------------------------------------------------------------------
3274
+
3275
+
3276
+ @router.post("/api/v1/findings/{finding_id}/comments", status_code=201, tags=["findings"])
3277
+ async def add_finding_comment(
3278
+ finding_id: str,
3279
+ author: Optional[str] = None,
3280
+ text: str = Query(..., min_length=1, description="Comment body text (required, non-empty)"),
3281
+ ) -> Dict[str, Any]:
3282
+ """Add a comment/annotation to a finding.
3283
+
3284
+ Comments are stored in-memory keyed by *finding_id*. Each comment
3285
+ records its author, text body, and creation timestamp.
3286
+
3287
+ Parameters
3288
+ ----------
3289
+ finding_id:
3290
+ Unique identifier of the finding to annotate.
3291
+ author:
3292
+ Display name of the commenter (defaults to ``"anonymous"`` when omitted).
3293
+ text:
3294
+ Comment body text (required, must be non-empty).
3295
+ """
3296
+ comment: Dict[str, Any] = {
3297
+ "id": str(uuid.uuid4()),
3298
+ "finding_id": finding_id,
3299
+ "author": author if author is not None else "anonymous",
3300
+ "text": text,
3301
+ "created_at": datetime.now(timezone.utc).isoformat(),
3302
+ }
3303
+ _finding_comments.setdefault(finding_id, []).append(comment)
3304
+
3305
+ # Broadcast team_annotation event to all WS clients (non-blocking)
3306
+ try:
3307
+ from mannf.product.dashboard import telemetry as _tel # noqa: PLC0415
3308
+ import asyncio # noqa: PLC0415
3309
+ loop = asyncio.get_running_loop()
3310
+ loop.create_task(_tel.broadcast_team_annotation(
3311
+ finding_id=finding_id,
3312
+ author=comment["author"],
3313
+ text=text,
3314
+ ))
3315
+ except Exception: # noqa: BLE001
3316
+ pass
3317
+
3318
+ return comment
3319
+
3320
+
3321
+ @router.get("/api/v1/findings/{finding_id}/comments", tags=["findings"])
3322
+ async def get_finding_comments(finding_id: str) -> List[Dict[str, Any]]:
3323
+ """Return all comments for a given finding, oldest first.
3324
+
3325
+ Parameters
3326
+ ----------
3327
+ finding_id:
3328
+ Unique identifier of the finding whose comments to retrieve.
3329
+ """
3330
+ return list(_finding_comments.get(finding_id, []))
3331
+
3332
+
3333
+ # ---------------------------------------------------------------------------
3334
+ # Share scan link — Phase 10.4
3335
+ # ---------------------------------------------------------------------------
3336
+
3337
+
3338
+ @router.post("/api/v1/scan/{scan_id}/share", status_code=201, tags=["scans"])
3339
+ async def share_scan(
3340
+ scan_id: str,
3341
+ expiry_hours: int = 24,
3342
+ ) -> Dict[str, Any]:
3343
+ """Generate a time-limited public share link for a scan.
3344
+
3345
+ The returned ``token`` can be embedded in a URL for read-only, auth-free
3346
+ access to the scan results. The link expires after *expiry_hours* hours
3347
+ (default 24 h; max 720 h / 30 days).
3348
+
3349
+ Parameters
3350
+ ----------
3351
+ scan_id:
3352
+ Unique identifier of the scan to share.
3353
+ expiry_hours:
3354
+ Hours until the link expires (1–720).
3355
+ """
3356
+ expiry_hours = max(1, min(expiry_hours, 720))
3357
+ token = str(uuid.uuid4())
3358
+ expires_at = datetime.now(timezone.utc) + timedelta(hours=expiry_hours)
3359
+ record: Dict[str, Any] = {
3360
+ "token": token,
3361
+ "scan_id": scan_id,
3362
+ "expires_at": expires_at.isoformat(),
3363
+ "created_at": datetime.now(timezone.utc).isoformat(),
3364
+ }
3365
+ _scan_shares[token] = record
3366
+ return record
3367
+
3368
+
3369
+ @router.get("/api/v1/analytics/calibration", response_model=CalibrationReport)
3370
+ async def get_calibration(
3371
+ target_url: Optional[str] = Query(None, description="Filter by target URL"),
3372
+ buckets: int = Query(10, description="Number of equal-width buckets (1–20)", ge=1, le=20),
3373
+ ) -> CalibrationReport:
3374
+ """Return a predicted-vs-actual reliability curve for the calibration dashboard view.
3375
+
3376
+ Buckets predictions into ``buckets`` equal-width ranges from 0.0 to 1.0
3377
+ and shows:
3378
+ - How many predictions fell in each bucket.
3379
+ - The actual failure rate observed in that bucket (from ``unique_failures``).
3380
+ - The mean predicted probability within the bucket.
3381
+
3382
+ Also computes the **Brier score** (mean squared error between predicted
3383
+ probability and actual outcome) when actual outcomes are available.
3384
+
3385
+ Handles sparse data gracefully: ``actual_failure_rate`` is ``None`` when
3386
+ no outcomes are available for a bucket.
3387
+
3388
+ Query parameters
3389
+ ----------------
3390
+ target_url:
3391
+ Optional target URL to filter runs.
3392
+ buckets:
3393
+ Number of equal-width buckets. Defaults to 10 (0.0–0.1, 0.1–0.2, …).
3394
+ """
3395
+ now_iso = datetime.now(timezone.utc).isoformat()
3396
+
3397
+ completed = [
3398
+ r
3399
+ for r in _autonomous_runs.values()
3400
+ if r.get("status") in {"complete", "stopped"}
3401
+ and (not target_url or r.get("target_url") == target_url)
3402
+ ]
3403
+ completed.sort(key=lambda r: r.get("started_at", ""), reverse=True)
3404
+ recent_runs = completed[:10] # use up to last 10 runs
3405
+
3406
+ if not recent_runs:
3407
+ return CalibrationReport(
3408
+ buckets=[],
3409
+ total_predictions=0,
3410
+ total_with_outcomes=0,
3411
+ brier_score=None,
3412
+ generated_at=now_iso,
3413
+ )
3414
+
3415
+ # Gather per-page: (predicted_score, actual_outcome) pairs
3416
+ # Use the same belief-momentum formula as /analytics/predictions to be consistent
3417
+ page_belief_history: Dict[str, List[float]] = {}
3418
+ page_failure_counts: Dict[str, int] = {}
3419
+ page_pass_counts: Dict[str, int] = {}
3420
+ total_failure_scenarios = 0
3421
+
3422
+ for run in recent_runs:
3423
+ for snap in run.get("belief_snapshots") or []:
3424
+ beliefs = snap.get("beliefs") or {}
3425
+ for pk, fl in beliefs.items():
3426
+ page_belief_history.setdefault(pk, []).append(float(fl))
3427
+
3428
+ for failure in run.get("unique_failures") or []:
3429
+ pk = failure.get("page", failure.get("url", ""))
3430
+ if pk:
3431
+ page_failure_counts[pk] = page_failure_counts.get(pk, 0) + 1
3432
+ total_failure_scenarios += 1
3433
+
3434
+ # Use iteration-level pass/fail info where available
3435
+ for iteration in run.get("iterations") or []:
3436
+ pass_count = iteration.get("passed", 0)
3437
+ fail_count = iteration.get("failed", 0)
3438
+ # We can't attribute these to individual pages from iteration-level data,
3439
+ # so we just use the per-page failure counts from unique_failures
3440
+
3441
+ all_pages = set(page_belief_history) | set(page_failure_counts)
3442
+
3443
+ # Build (predicted, actual) pairs for each page with enough data
3444
+ data_points: List[tuple[float, int]] = [] # (predicted, 0|1)
3445
+
3446
+ for pk in all_pages:
3447
+ belief_vals = page_belief_history.get(pk, [])
3448
+ if len(belief_vals) >= 2:
3449
+ deltas = [belief_vals[i] - belief_vals[i - 1] for i in range(1, len(belief_vals))]
3450
+ belief_momentum = sum(deltas) / len(deltas)
3451
+ else:
3452
+ belief_momentum = 0.0
3453
+
3454
+ latest_belief = belief_vals[-1] if belief_vals else 0.5
3455
+ failure_density = (
3456
+ page_failure_counts.get(pk, 0) / total_failure_scenarios
3457
+ if total_failure_scenarios > 0
3458
+ else 0.0
3459
+ )
3460
+ raw_prob = (
3461
+ 0.5 * latest_belief
3462
+ + 0.3 * max(0.0, belief_momentum)
3463
+ + 0.2 * failure_density
3464
+ )
3465
+ predicted_prob = max(0.0, min(1.0, raw_prob))
3466
+
3467
+ # Actual outcome: 1 if failed at least once across runs, 0 otherwise
3468
+ actual = 1 if page_failure_counts.get(pk, 0) > 0 else 0
3469
+ data_points.append((predicted_prob, actual))
3470
+
3471
+ # Build reliability curve buckets
3472
+ bucket_width = 1.0 / buckets
3473
+ bucket_data: List[tuple[float, float, List[tuple[float, int]]]] = []
3474
+ for i in range(buckets):
3475
+ low = round(i * bucket_width, 10)
3476
+ high = round((i + 1) * bucket_width, 10)
3477
+ bucket_data.append((low, high, []))
3478
+
3479
+ for pred, actual in data_points:
3480
+ idx = min(int(pred / bucket_width), buckets - 1)
3481
+ bucket_data[idx][2].append((pred, actual))
3482
+
3483
+ calibration_buckets: List[CalibrationBucket] = []
3484
+ brier_sum = 0.0
3485
+ total_with_outcomes = 0
3486
+
3487
+ for low, high, points in bucket_data:
3488
+ n = len(points)
3489
+ if n == 0:
3490
+ calibration_buckets.append(
3491
+ CalibrationBucket(
3492
+ bucket_low=round(low, 4),
3493
+ bucket_high=round(high, 4),
3494
+ predicted_count=0,
3495
+ actual_failure_rate=None,
3496
+ mean_predicted=None,
3497
+ )
3498
+ )
3499
+ else:
3500
+ actuals = [p[1] for p in points]
3501
+ preds = [p[0] for p in points]
3502
+ actual_rate = sum(actuals) / n
3503
+ mean_pred = sum(preds) / n
3504
+ for pred_v, act_v in points:
3505
+ brier_sum += (pred_v - act_v) ** 2
3506
+ total_with_outcomes += n
3507
+ calibration_buckets.append(
3508
+ CalibrationBucket(
3509
+ bucket_low=round(low, 4),
3510
+ bucket_high=round(high, 4),
3511
+ predicted_count=n,
3512
+ actual_failure_rate=round(actual_rate, 4),
3513
+ mean_predicted=round(mean_pred, 4),
3514
+ )
3515
+ )
3516
+
3517
+ brier_score = round(brier_sum / total_with_outcomes, 4) if total_with_outcomes > 0 else None
3518
+
3519
+ return CalibrationReport(
3520
+ buckets=calibration_buckets,
3521
+ total_predictions=len(data_points),
3522
+ total_with_outcomes=total_with_outcomes,
3523
+ brier_score=brier_score,
3524
+ generated_at=now_iso,
3525
+ )
3526
+
3527
+
3528
+ # ---------------------------------------------------------------------------
3529
+ # Worker pool status endpoint
3530
+ # ---------------------------------------------------------------------------
3531
+
3532
+
3533
+ @router.get(
3534
+ "/api/v1/worker-pool/status",
3535
+ tags=["worker-pool"],
3536
+ summary="Get worker pool and queue status",
3537
+ )
3538
+ async def get_worker_pool_status() -> Dict[str, Any]:
3539
+ """Return the current state of the process-wide worker pool and job queue.
3540
+
3541
+ When no worker pool is configured (default single-process mode) the
3542
+ response contains null pool data with the current in-memory queue stats.
3543
+ """
3544
+ from mannf.core.agents.worker_pool import get_default_pool # noqa: PLC0415
3545
+ from mannf.product.scheduling.queue import get_default_queue # noqa: PLC0415
3546
+
3547
+ pool = get_default_pool()
3548
+ queue = get_default_queue()
3549
+
3550
+ pool_info: Dict[str, Any] = {
3551
+ "configured": pool is not None,
3552
+ }
3553
+ if pool is not None:
3554
+ pool_info.update(pool.info())
3555
+
3556
+ queue_stats: Dict[str, Any] = {}
3557
+ if queue is not None:
3558
+ try:
3559
+ stats = await queue.status()
3560
+ queue_stats = stats.to_dict()
3561
+ except Exception: # noqa: BLE001
3562
+ queue_stats = {}
3563
+
3564
+ return {
3565
+ "pool": pool_info,
3566
+ "queue": queue_stats,
3567
+ }