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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,366 @@
|
|
|
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
|
+
"""Worker pool orchestration for large-scale distributed NAT execution.
|
|
7
|
+
|
|
8
|
+
The :class:`WorkerPool` manages a pool of concurrent asyncio workers that
|
|
9
|
+
pull jobs from a shared :class:`~mannf.product.scheduling.queue.JobQueue` and
|
|
10
|
+
execute them. Workers are implemented as asyncio coroutines (lightweight),
|
|
11
|
+
enabling hundreds of parallel scan/test jobs on a single host.
|
|
12
|
+
|
|
13
|
+
For multi-host / cluster deployments, use :class:`RedisJobQueue` as the
|
|
14
|
+
backend — all nodes then compete for jobs via the same distributed queue.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from mannf.core.agents.worker_pool import WorkerPool
|
|
19
|
+
from mannf.product.scheduling.queue import InMemoryJobQueue
|
|
20
|
+
|
|
21
|
+
queue = InMemoryJobQueue()
|
|
22
|
+
pool = WorkerPool(queue=queue, worker_count=4)
|
|
23
|
+
|
|
24
|
+
await pool.start()
|
|
25
|
+
|
|
26
|
+
job_id = await queue.enqueue("scan", {"scan_id": "abc", …})
|
|
27
|
+
# Workers will pick up the job automatically.
|
|
28
|
+
|
|
29
|
+
await pool.stop()
|
|
30
|
+
info = pool.info() # dict with worker states, queue stats, etc.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import logging
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime, timezone
|
|
41
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
|
42
|
+
|
|
43
|
+
from mannf.product.scheduling.queue import Job, JobQueue
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Type alias for job handler coroutine functions.
|
|
48
|
+
JobHandler = Callable[[Job], Coroutine[Any, Any, None]]
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Worker state
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class WorkerInfo:
|
|
57
|
+
"""Snapshot of a single worker's state."""
|
|
58
|
+
|
|
59
|
+
worker_id: str
|
|
60
|
+
status: str = "idle" # idle | running | error | stopped
|
|
61
|
+
current_job_id: Optional[str] = None
|
|
62
|
+
jobs_completed: int = 0
|
|
63
|
+
jobs_failed: int = 0
|
|
64
|
+
last_heartbeat: float = field(default_factory=time.time)
|
|
65
|
+
started_at: float = field(default_factory=time.time)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"worker_id": self.worker_id,
|
|
70
|
+
"status": self.status,
|
|
71
|
+
"current_job_id": self.current_job_id,
|
|
72
|
+
"jobs_completed": self.jobs_completed,
|
|
73
|
+
"jobs_failed": self.jobs_failed,
|
|
74
|
+
"last_heartbeat": self.last_heartbeat,
|
|
75
|
+
"started_at": self.started_at,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# WorkerPool
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WorkerPool:
|
|
85
|
+
"""Asyncio worker pool that consumes jobs from a :class:`JobQueue`.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
queue:
|
|
90
|
+
The job queue to pull jobs from.
|
|
91
|
+
worker_count:
|
|
92
|
+
Number of concurrent worker coroutines to run.
|
|
93
|
+
poll_interval:
|
|
94
|
+
Seconds to sleep when the queue is empty before polling again.
|
|
95
|
+
max_attempts:
|
|
96
|
+
Maximum number of retry attempts for a failed job.
|
|
97
|
+
heartbeat_interval:
|
|
98
|
+
Seconds between worker heartbeat updates.
|
|
99
|
+
handlers:
|
|
100
|
+
Mapping of ``job_type`` → async callable. The callable receives the
|
|
101
|
+
:class:`~mannf.product.scheduling.queue.Job` and should raise on
|
|
102
|
+
failure. If a job type has no registered handler the job is nack'd
|
|
103
|
+
as failed immediately.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
queue: JobQueue,
|
|
109
|
+
worker_count: int = 4,
|
|
110
|
+
poll_interval: float = 1.0,
|
|
111
|
+
max_attempts: int = 3,
|
|
112
|
+
heartbeat_interval: float = 10.0,
|
|
113
|
+
handlers: Optional[Dict[str, JobHandler]] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self._queue = queue
|
|
116
|
+
self._worker_count = worker_count
|
|
117
|
+
self._poll_interval = poll_interval
|
|
118
|
+
self._max_attempts = max_attempts
|
|
119
|
+
self._heartbeat_interval = heartbeat_interval
|
|
120
|
+
self._handlers: Dict[str, JobHandler] = handlers or {}
|
|
121
|
+
|
|
122
|
+
self._workers: Dict[str, WorkerInfo] = {}
|
|
123
|
+
self._tasks: List[asyncio.Task[None]] = []
|
|
124
|
+
self._running = False
|
|
125
|
+
self._pool_id = str(uuid.uuid4())[:8]
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Handler registration
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def register_handler(self, job_type: str, handler: JobHandler) -> None:
|
|
132
|
+
"""Register an async *handler* for jobs of *job_type*."""
|
|
133
|
+
self._handlers[job_type] = handler
|
|
134
|
+
logger.debug("WorkerPool[%s]: registered handler for job_type=%r", self._pool_id, job_type)
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------
|
|
137
|
+
# Lifecycle
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
async def start(self) -> None:
|
|
141
|
+
"""Launch all worker coroutines. Returns immediately."""
|
|
142
|
+
if self._running:
|
|
143
|
+
logger.warning("WorkerPool[%s] already running", self._pool_id)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
self._running = True
|
|
147
|
+
for i in range(self._worker_count):
|
|
148
|
+
worker_id = f"worker-{self._pool_id}-{i}"
|
|
149
|
+
info = WorkerInfo(worker_id=worker_id)
|
|
150
|
+
self._workers[worker_id] = info
|
|
151
|
+
task = asyncio.create_task(
|
|
152
|
+
self._worker_loop(worker_id),
|
|
153
|
+
name=f"nat-worker-{worker_id}",
|
|
154
|
+
)
|
|
155
|
+
self._tasks.append(task)
|
|
156
|
+
|
|
157
|
+
logger.info(
|
|
158
|
+
"WorkerPool[%s] started with %d workers",
|
|
159
|
+
self._pool_id,
|
|
160
|
+
self._worker_count,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async def stop(self, timeout: float = 30.0) -> None:
|
|
164
|
+
"""Signal workers to stop and wait for in-flight jobs to finish.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
timeout:
|
|
169
|
+
Maximum seconds to wait for a clean drain. Workers still running
|
|
170
|
+
after the timeout are cancelled.
|
|
171
|
+
"""
|
|
172
|
+
self._running = False
|
|
173
|
+
logger.info("WorkerPool[%s] stopping (timeout=%.1fs)…", self._pool_id, timeout)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
await asyncio.wait_for(
|
|
177
|
+
asyncio.gather(*self._tasks, return_exceptions=True),
|
|
178
|
+
timeout=timeout,
|
|
179
|
+
)
|
|
180
|
+
except asyncio.TimeoutError:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"WorkerPool[%s] drain timeout exceeded; cancelling remaining workers",
|
|
183
|
+
self._pool_id,
|
|
184
|
+
)
|
|
185
|
+
for task in self._tasks:
|
|
186
|
+
if not task.done():
|
|
187
|
+
task.cancel()
|
|
188
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
189
|
+
|
|
190
|
+
self._tasks.clear()
|
|
191
|
+
for info in self._workers.values():
|
|
192
|
+
info.status = "stopped"
|
|
193
|
+
logger.info("WorkerPool[%s] stopped", self._pool_id)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def is_running(self) -> bool:
|
|
197
|
+
"""``True`` while the pool's worker tasks are active."""
|
|
198
|
+
return self._running and any(not t.done() for t in self._tasks)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Scale up / down
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async def scale(self, target_count: int) -> None:
|
|
205
|
+
"""Adjust the number of running workers to *target_count*.
|
|
206
|
+
|
|
207
|
+
Workers are added or gracefully removed to reach the target.
|
|
208
|
+
"""
|
|
209
|
+
current = sum(1 for t in self._tasks if not t.done())
|
|
210
|
+
delta = target_count - current
|
|
211
|
+
|
|
212
|
+
if delta > 0:
|
|
213
|
+
for _ in range(delta):
|
|
214
|
+
i = len(self._workers)
|
|
215
|
+
worker_id = f"worker-{self._pool_id}-{i}"
|
|
216
|
+
info = WorkerInfo(worker_id=worker_id)
|
|
217
|
+
self._workers[worker_id] = info
|
|
218
|
+
task = asyncio.create_task(
|
|
219
|
+
self._worker_loop(worker_id),
|
|
220
|
+
name=f"nat-worker-{worker_id}",
|
|
221
|
+
)
|
|
222
|
+
self._tasks.append(task)
|
|
223
|
+
logger.info(
|
|
224
|
+
"WorkerPool[%s] scaled up to %d workers", self._pool_id, target_count
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
elif delta < 0:
|
|
228
|
+
# Cancel the newest idle workers first
|
|
229
|
+
removed = 0
|
|
230
|
+
for task in reversed(self._tasks):
|
|
231
|
+
if removed >= abs(delta):
|
|
232
|
+
break
|
|
233
|
+
if not task.done():
|
|
234
|
+
task.cancel()
|
|
235
|
+
removed += 1
|
|
236
|
+
logger.info(
|
|
237
|
+
"WorkerPool[%s] scaled down to ~%d workers", self._pool_id, target_count
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self._worker_count = target_count
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Info / status
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def info(self) -> Dict[str, Any]:
|
|
247
|
+
"""Return a serialisable snapshot of pool state."""
|
|
248
|
+
active = sum(1 for t in self._tasks if not t.done())
|
|
249
|
+
return {
|
|
250
|
+
"pool_id": self._pool_id,
|
|
251
|
+
"worker_count": self._worker_count,
|
|
252
|
+
"active_tasks": active,
|
|
253
|
+
"is_running": self.is_running,
|
|
254
|
+
"workers": [w.to_dict() for w in self._workers.values()],
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async def full_status(self) -> Dict[str, Any]:
|
|
258
|
+
"""Return pool info combined with live queue stats."""
|
|
259
|
+
queue_stats = await self._queue.status()
|
|
260
|
+
data = self.info()
|
|
261
|
+
data["queue"] = queue_stats.to_dict()
|
|
262
|
+
return data
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Internal worker loop
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
async def _worker_loop(self, worker_id: str) -> None:
|
|
269
|
+
"""Main coroutine for a single worker.
|
|
270
|
+
|
|
271
|
+
Continuously dequeues jobs and dispatches them to the registered
|
|
272
|
+
handler. Backs off with `_poll_interval` when the queue is empty.
|
|
273
|
+
"""
|
|
274
|
+
info = self._workers[worker_id]
|
|
275
|
+
logger.debug("Worker %s started", worker_id)
|
|
276
|
+
|
|
277
|
+
while self._running:
|
|
278
|
+
info.last_heartbeat = time.time()
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
job = await self._queue.dequeue(worker_id=worker_id)
|
|
282
|
+
except Exception: # noqa: BLE001
|
|
283
|
+
logger.exception("Worker %s: error dequeuing job", worker_id)
|
|
284
|
+
await asyncio.sleep(self._poll_interval)
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
if job is None:
|
|
288
|
+
# Queue is empty — back off
|
|
289
|
+
info.status = "idle"
|
|
290
|
+
info.current_job_id = None
|
|
291
|
+
await asyncio.sleep(self._poll_interval)
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
info.status = "running"
|
|
295
|
+
info.current_job_id = job.job_id
|
|
296
|
+
logger.info(
|
|
297
|
+
"Worker %s: picked up job %s (type=%s, attempt=%d)",
|
|
298
|
+
worker_id,
|
|
299
|
+
job.job_id,
|
|
300
|
+
job.job_type,
|
|
301
|
+
job.attempt,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
await self._dispatch(job)
|
|
306
|
+
await self._queue.ack(job.job_id)
|
|
307
|
+
info.jobs_completed += 1
|
|
308
|
+
logger.info("Worker %s: job %s completed", worker_id, job.job_id)
|
|
309
|
+
except asyncio.CancelledError:
|
|
310
|
+
# Worker is being stopped — nack without retry
|
|
311
|
+
await self._queue.nack(
|
|
312
|
+
job.job_id,
|
|
313
|
+
error="Worker cancelled",
|
|
314
|
+
retry=False,
|
|
315
|
+
)
|
|
316
|
+
raise
|
|
317
|
+
except Exception as exc: # noqa: BLE001
|
|
318
|
+
info.jobs_failed += 1
|
|
319
|
+
error_msg = str(exc)
|
|
320
|
+
should_retry = job.attempt < self._max_attempts
|
|
321
|
+
logger.warning(
|
|
322
|
+
"Worker %s: job %s failed (attempt %d/%d, retry=%s): %s",
|
|
323
|
+
worker_id,
|
|
324
|
+
job.job_id,
|
|
325
|
+
job.attempt,
|
|
326
|
+
self._max_attempts,
|
|
327
|
+
should_retry,
|
|
328
|
+
error_msg,
|
|
329
|
+
)
|
|
330
|
+
await self._queue.nack(
|
|
331
|
+
job.job_id,
|
|
332
|
+
error=error_msg,
|
|
333
|
+
retry=should_retry,
|
|
334
|
+
max_attempts=self._max_attempts,
|
|
335
|
+
)
|
|
336
|
+
finally:
|
|
337
|
+
info.current_job_id = None
|
|
338
|
+
info.status = "idle"
|
|
339
|
+
|
|
340
|
+
info.status = "stopped"
|
|
341
|
+
logger.debug("Worker %s exited", worker_id)
|
|
342
|
+
|
|
343
|
+
async def _dispatch(self, job: Job) -> None:
|
|
344
|
+
"""Dispatch a job to its registered handler."""
|
|
345
|
+
handler = self._handlers.get(job.job_type)
|
|
346
|
+
if handler is None:
|
|
347
|
+
raise ValueError(f"No handler registered for job_type={job.job_type!r}")
|
|
348
|
+
await handler(job)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Module-level singleton
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
_default_pool: Optional[WorkerPool] = None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_default_pool() -> Optional[WorkerPool]:
|
|
359
|
+
"""Return the process-wide default :class:`WorkerPool`, or ``None``."""
|
|
360
|
+
return _default_pool
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def set_default_pool(pool: Optional[WorkerPool]) -> None:
|
|
364
|
+
"""Set the process-wide default :class:`WorkerPool`."""
|
|
365
|
+
global _default_pool
|
|
366
|
+
_default_pool = pool
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
"""Anomaly detection sub-package for NAT."""
|
|
7
|
+
|
|
8
|
+
from mannf.core.anomaly.enhanced_detector import AnomalyAlert, AnomalyType, EnhancedAnomalyDetector
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"EnhancedAnomalyDetector",
|
|
12
|
+
"AnomalyAlert",
|
|
13
|
+
"AnomalyType",
|
|
14
|
+
]
|