aetherruntime-arch 2.0.0__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.
- aetherruntime/__init__.py +314 -0
- aetherruntime/__version__.py +1 -0
- aetherruntime/cli.py +306 -0
- aetherruntime/containment/__init__.py +39 -0
- aetherruntime/containment/isolation.py +253 -0
- aetherruntime/containment/policy.py +273 -0
- aetherruntime/detection/__init__.py +35 -0
- aetherruntime/detection/alert.py +190 -0
- aetherruntime/detection/classifier.py +298 -0
- aetherruntime/halt/__init__.py +37 -0
- aetherruntime/halt/order.py +248 -0
- aetherruntime/halt/quorum.py +280 -0
- aetherruntime/models.py +489 -0
- aetherruntime/monitoring/__init__.py +33 -0
- aetherruntime/monitoring/baseline.py +282 -0
- aetherruntime/monitoring/telemetry.py +235 -0
- aetherruntime/py.typed +0 -0
- aetherruntime/recovery/__init__.py +37 -0
- aetherruntime/recovery/replay.py +271 -0
- aetherruntime/recovery/snapshot.py +199 -0
- aetherruntime/simulation/__init__.py +37 -0
- aetherruntime/simulation/network.py +216 -0
- aetherruntime/simulation/node.py +341 -0
- aetherruntime/simulation/testbed.py +262 -0
- aetherruntime_arch-2.0.0.dist-info/METADATA +748 -0
- aetherruntime_arch-2.0.0.dist-info/RECORD +30 -0
- aetherruntime_arch-2.0.0.dist-info/WHEEL +5 -0
- aetherruntime_arch-2.0.0.dist-info/entry_points.txt +2 -0
- aetherruntime_arch-2.0.0.dist-info/licenses/LICENSE +26 -0
- aetherruntime_arch-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Monitoring: Baseline Computation
|
|
3
|
+
|
|
4
|
+
This module implements Operation 2 (`update_baseline`) from
|
|
5
|
+
docs/specification/02_operations.md.
|
|
6
|
+
|
|
7
|
+
The `update_baseline` operation computes a statistical model of
|
|
8
|
+
expected behaviour for an agent's telemetry category. It is the
|
|
9
|
+
reference point for anomaly detection (Layer 2).
|
|
10
|
+
|
|
11
|
+
Invariants enforced:
|
|
12
|
+
- I7: sample_count >= 10
|
|
13
|
+
- I8: parameters contains required keys for model_type
|
|
14
|
+
|
|
15
|
+
Errors:
|
|
16
|
+
- E5: AgentNotFound
|
|
17
|
+
- E6: InsufficientTelemetry
|
|
18
|
+
- E7: InvalidWindowSize
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import statistics
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from ..models import (
|
|
28
|
+
Agent,
|
|
29
|
+
Baseline,
|
|
30
|
+
BaselineModel,
|
|
31
|
+
TelemetryCategory,
|
|
32
|
+
TelemetryRecord,
|
|
33
|
+
_now_iso,
|
|
34
|
+
_random_hex,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
39
|
+
# Errors
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentNotFound(Exception):
|
|
44
|
+
"""E5: Agent does not exist."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InsufficientTelemetry(Exception):
|
|
49
|
+
"""E6: Fewer than window_size telemetry records exist."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidWindowSize(Exception):
|
|
54
|
+
"""E7: window_size < 10."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
# Operation 2 — update_baseline
|
|
60
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def update_baseline(
|
|
64
|
+
agent: Agent,
|
|
65
|
+
category: TelemetryCategory,
|
|
66
|
+
model_type: BaselineModel,
|
|
67
|
+
window_size: int,
|
|
68
|
+
telemetry_records: list[TelemetryRecord],
|
|
69
|
+
existing_baseline: Optional[Baseline] = None,
|
|
70
|
+
) -> Baseline:
|
|
71
|
+
"""
|
|
72
|
+
Update the baseline model for an agent's telemetry category (Operation 2).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
agent: The agent to compute a baseline for.
|
|
76
|
+
category: Telemetry category (cpu, memory, network, syscall, action).
|
|
77
|
+
model_type: Baseline model type (mean_std, zscore, moving_average, seasonal).
|
|
78
|
+
window_size: Number of recent telemetry records to use.
|
|
79
|
+
telemetry_records: All telemetry records for the agent.
|
|
80
|
+
existing_baseline: Optional existing baseline (to preserve baseline_id).
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A new (or updated) `Baseline` with computed parameters.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
AgentNotFound: If agent is None.
|
|
87
|
+
InvalidWindowSize: If window_size < 10.
|
|
88
|
+
InsufficientTelemetry: If fewer than window_size records exist.
|
|
89
|
+
|
|
90
|
+
Invariants:
|
|
91
|
+
I7: sample_count >= 10.
|
|
92
|
+
I8: parameters contains all required keys for model_type.
|
|
93
|
+
|
|
94
|
+
Example:
|
|
95
|
+
>>> agent = create_agent(public_key=...)
|
|
96
|
+
>>> records = [collect(agent, TelemetryCategory.CPU, 45.0) for _ in range(10)]
|
|
97
|
+
>>> baseline = update_baseline(agent, TelemetryCategory.CPU,
|
|
98
|
+
... BaselineModel.MEAN_STD, 10, records)
|
|
99
|
+
>>> baseline.sample_count
|
|
100
|
+
10
|
|
101
|
+
"""
|
|
102
|
+
# ─── P1: Agent exists ───
|
|
103
|
+
if agent is None:
|
|
104
|
+
raise AgentNotFound("agent must not be None")
|
|
105
|
+
|
|
106
|
+
# ─── P2: window_size >= 10 (I7) ───
|
|
107
|
+
if window_size < 10:
|
|
108
|
+
raise InvalidWindowSize(
|
|
109
|
+
f"window_size must be >= 10, got {window_size}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# ─── P3: Enough telemetry records ───
|
|
113
|
+
relevant = [r for r in telemetry_records if r.category == category]
|
|
114
|
+
if len(relevant) < window_size:
|
|
115
|
+
raise InsufficientTelemetry(
|
|
116
|
+
f"need >= {window_size} records for category {category.value}, "
|
|
117
|
+
f"got {len(relevant)}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Use the most recent `window_size` records
|
|
121
|
+
recent = relevant[-window_size:]
|
|
122
|
+
values = [r.value for r in recent]
|
|
123
|
+
|
|
124
|
+
# ─── Compute parameters based on model_type ───
|
|
125
|
+
parameters = _compute_parameters(model_type, values)
|
|
126
|
+
|
|
127
|
+
# ─── Create or update baseline ───
|
|
128
|
+
baseline_id = existing_baseline.baseline_id if existing_baseline else _random_hex()
|
|
129
|
+
|
|
130
|
+
return Baseline(
|
|
131
|
+
baseline_id=baseline_id,
|
|
132
|
+
agent_id=agent.agent_id,
|
|
133
|
+
category=category,
|
|
134
|
+
model_type=model_type,
|
|
135
|
+
parameters=parameters,
|
|
136
|
+
computed_at=_now_iso(),
|
|
137
|
+
sample_count=len(recent),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _compute_parameters(model_type: BaselineModel, values: list[float]) -> dict:
|
|
142
|
+
"""Compute model parameters from a list of values."""
|
|
143
|
+
if model_type == BaselineModel.MEAN_STD:
|
|
144
|
+
return {
|
|
145
|
+
"mean": statistics.mean(values),
|
|
146
|
+
"std": statistics.stdev(values) if len(values) > 1 else 0.0,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
elif model_type == BaselineModel.ZSCORE:
|
|
150
|
+
mean = statistics.mean(values)
|
|
151
|
+
std = statistics.stdev(values) if len(values) > 1 else 0.0
|
|
152
|
+
return {
|
|
153
|
+
"mean": mean,
|
|
154
|
+
"std": std,
|
|
155
|
+
"z_scores": [(v - mean) / std if std > 0 else 0.0 for v in values],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
elif model_type == BaselineModel.MOVING_AVERAGE:
|
|
159
|
+
return {
|
|
160
|
+
"window": len(values),
|
|
161
|
+
"values": values,
|
|
162
|
+
"moving_average": statistics.mean(values),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
elif model_type == BaselineModel.SEASONAL:
|
|
166
|
+
# Simple seasonal decomposition: period = 4 (quarterly)
|
|
167
|
+
period = 4
|
|
168
|
+
return {
|
|
169
|
+
"period": period,
|
|
170
|
+
"coefficients": _seasonal_coefficients(values, period),
|
|
171
|
+
"mean": statistics.mean(values),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
else:
|
|
175
|
+
raise ValueError(f"Unknown model_type: {model_type}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _seasonal_coefficients(values: list[float], period: int) -> list[float]:
|
|
179
|
+
"""Compute simple seasonal coefficients."""
|
|
180
|
+
if len(values) < period:
|
|
181
|
+
return [1.0] * period
|
|
182
|
+
overall_mean = statistics.mean(values)
|
|
183
|
+
if overall_mean == 0:
|
|
184
|
+
return [1.0] * period
|
|
185
|
+
coefficients = []
|
|
186
|
+
for i in range(period):
|
|
187
|
+
seasonal_values = values[i::period]
|
|
188
|
+
if seasonal_values:
|
|
189
|
+
coefficients.append(statistics.mean(seasonal_values) / overall_mean)
|
|
190
|
+
else:
|
|
191
|
+
coefficients.append(1.0)
|
|
192
|
+
return coefficients
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
196
|
+
# Batch baseline helper
|
|
197
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def update_all_baselines(
|
|
201
|
+
agent: Agent,
|
|
202
|
+
telemetry_records: list[TelemetryRecord],
|
|
203
|
+
model_type: BaselineModel = BaselineModel.MEAN_STD,
|
|
204
|
+
window_size: int = 10,
|
|
205
|
+
) -> dict[TelemetryCategory, Baseline]:
|
|
206
|
+
"""
|
|
207
|
+
Update baselines for all telemetry categories with sufficient data.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
agent: The agent.
|
|
211
|
+
telemetry_records: All telemetry records.
|
|
212
|
+
model_type: Model type (default: mean_std).
|
|
213
|
+
window_size: Window size (default: 10).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Dict mapping category → Baseline (only for categories with enough data).
|
|
217
|
+
"""
|
|
218
|
+
baselines: dict[TelemetryCategory, Baseline] = {}
|
|
219
|
+
for category in TelemetryCategory:
|
|
220
|
+
relevant = [r for r in telemetry_records if r.category == category]
|
|
221
|
+
if len(relevant) >= window_size:
|
|
222
|
+
try:
|
|
223
|
+
baseline = update_baseline(
|
|
224
|
+
agent=agent,
|
|
225
|
+
category=category,
|
|
226
|
+
model_type=model_type,
|
|
227
|
+
window_size=window_size,
|
|
228
|
+
telemetry_records=telemetry_records,
|
|
229
|
+
)
|
|
230
|
+
baselines[category] = baseline
|
|
231
|
+
except (InsufficientTelemetry, InvalidWindowSize):
|
|
232
|
+
continue
|
|
233
|
+
return baselines
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
237
|
+
# Baseline registry
|
|
238
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class BaselineRegistry:
|
|
242
|
+
"""
|
|
243
|
+
An in-memory registry of baselines.
|
|
244
|
+
|
|
245
|
+
Used by the detection layer (Layer 2) to look up the baseline
|
|
246
|
+
for a given (agent, category) pair.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self) -> None:
|
|
250
|
+
self._baselines: dict[tuple[str, TelemetryCategory], Baseline] = {}
|
|
251
|
+
|
|
252
|
+
def put(self, baseline: Baseline) -> None:
|
|
253
|
+
"""Register a baseline."""
|
|
254
|
+
key = (baseline.agent_id, baseline.category)
|
|
255
|
+
self._baselines[key] = baseline
|
|
256
|
+
|
|
257
|
+
def get(
|
|
258
|
+
self, agent_id: str, category: TelemetryCategory
|
|
259
|
+
) -> Optional[Baseline]:
|
|
260
|
+
"""Get the baseline for an agent and category."""
|
|
261
|
+
return self._baselines.get((agent_id, category))
|
|
262
|
+
|
|
263
|
+
def get_all(self, agent_id: str) -> dict[TelemetryCategory, Baseline]:
|
|
264
|
+
"""Get all baselines for an agent."""
|
|
265
|
+
return {
|
|
266
|
+
cat: bl
|
|
267
|
+
for (aid, cat), bl in self._baselines.items()
|
|
268
|
+
if aid == agent_id
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
def has(self, agent_id: str, category: TelemetryCategory) -> bool:
|
|
272
|
+
"""Check if a baseline exists."""
|
|
273
|
+
return (agent_id, category) in self._baselines
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
277
|
+
# Module-level singleton (optional)
|
|
278
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
default_baseline_registry = BaselineRegistry()
|
|
282
|
+
"""Default baseline registry (can be replaced per-test)."""
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Monitoring: Telemetry Collection
|
|
3
|
+
|
|
4
|
+
This module implements Operation 1 (`collect`) from
|
|
5
|
+
docs/specification/02_operations.md.
|
|
6
|
+
|
|
7
|
+
The `collect` operation gathers a telemetry record from a running agent.
|
|
8
|
+
It is the input to the detection layer (Layer 2).
|
|
9
|
+
|
|
10
|
+
Invariants enforced:
|
|
11
|
+
- I4: telemetry_id is unique (via _random_hex)
|
|
12
|
+
- I5: value is finite
|
|
13
|
+
- I6: timestamp is monotonic per agent
|
|
14
|
+
|
|
15
|
+
Errors:
|
|
16
|
+
- E2: AgentNotRunning
|
|
17
|
+
- E3: InvalidValue
|
|
18
|
+
- E4: NonMonotonicTimestamp
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from ..models import (
|
|
26
|
+
Agent,
|
|
27
|
+
AgentStatus,
|
|
28
|
+
TelemetryCategory,
|
|
29
|
+
TelemetryRecord,
|
|
30
|
+
_now_iso,
|
|
31
|
+
_random_hex,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
# Errors
|
|
37
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentNotRunning(Exception):
|
|
41
|
+
"""E2: Agent is not in `running` state."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidValue(Exception):
|
|
46
|
+
"""E3: Value is not finite."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NonMonotonicTimestamp(Exception):
|
|
51
|
+
"""E4: Timestamp is not monotonic per agent."""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
56
|
+
# Operation 1 — collect
|
|
57
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def collect(
|
|
61
|
+
agent: Agent,
|
|
62
|
+
category: TelemetryCategory,
|
|
63
|
+
value: float,
|
|
64
|
+
timestamp: Optional[str] = None,
|
|
65
|
+
metadata: Optional[dict] = None,
|
|
66
|
+
previous_timestamp: Optional[str] = None,
|
|
67
|
+
) -> TelemetryRecord:
|
|
68
|
+
"""
|
|
69
|
+
Collect a telemetry record from a running agent (Operation 1).
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
agent: The agent to collect from. Must be in `running` state.
|
|
73
|
+
category: Telemetry category (cpu, memory, network, syscall, action).
|
|
74
|
+
value: Numeric value (unit depends on category). Must be finite.
|
|
75
|
+
timestamp: Logical time of observation (ISO 8601). Defaults to now.
|
|
76
|
+
metadata: Optional metadata (pid, tid, etc.).
|
|
77
|
+
previous_timestamp: Optional previous timestamp for monotonicity check.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A new `TelemetryRecord` with a unique `telemetry_id`.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
AgentNotRunning: If agent.status != AgentStatus.RUNNING.
|
|
84
|
+
InvalidValue: If value is NaN or Inf.
|
|
85
|
+
NonMonotonicTimestamp: If timestamp < previous_timestamp.
|
|
86
|
+
|
|
87
|
+
Invariants:
|
|
88
|
+
I4: telemetry_id is unique (via _random_hex).
|
|
89
|
+
I5: value is finite.
|
|
90
|
+
I6: timestamp is monotonic per agent (if previous_timestamp provided).
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
>>> agent = create_agent(public_key=...)
|
|
94
|
+
>>> agent.status = AgentStatus.RUNNING
|
|
95
|
+
>>> record = collect(agent, TelemetryCategory.CPU, 45.2)
|
|
96
|
+
>>> record.value
|
|
97
|
+
45.2
|
|
98
|
+
"""
|
|
99
|
+
# ─── P1: Agent must be running ───
|
|
100
|
+
if agent.status != AgentStatus.RUNNING:
|
|
101
|
+
raise AgentNotRunning(
|
|
102
|
+
f"Agent {agent.agent_id} is not running (status: {agent.status.value})"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# ─── P2: Value must be finite (I5) ───
|
|
106
|
+
if not isinstance(value, (int, float)):
|
|
107
|
+
raise InvalidValue(f"value must be int or float, got {type(value)}")
|
|
108
|
+
if value != value: # NaN check
|
|
109
|
+
raise InvalidValue("value must not be NaN")
|
|
110
|
+
if value == float("inf") or value == float("-inf"):
|
|
111
|
+
raise InvalidValue("value must not be Inf")
|
|
112
|
+
|
|
113
|
+
# ─── P3: Timestamp must be monotonic (I6) ───
|
|
114
|
+
if timestamp is None:
|
|
115
|
+
timestamp = _now_iso()
|
|
116
|
+
if previous_timestamp is not None:
|
|
117
|
+
from datetime import datetime
|
|
118
|
+
try:
|
|
119
|
+
current_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
120
|
+
previous_dt = datetime.fromisoformat(previous_timestamp.replace("Z", "+00:00"))
|
|
121
|
+
except (ValueError, AttributeError) as e:
|
|
122
|
+
raise NonMonotonicTimestamp(f"Invalid timestamp format: {e}")
|
|
123
|
+
if current_dt < previous_dt:
|
|
124
|
+
raise NonMonotonicTimestamp(
|
|
125
|
+
f"timestamp {timestamp} is before previous_timestamp {previous_timestamp}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# ─── Q1–Q3: Create the record ───
|
|
129
|
+
return TelemetryRecord(
|
|
130
|
+
telemetry_id=_random_hex(),
|
|
131
|
+
agent_id=agent.agent_id,
|
|
132
|
+
category=category,
|
|
133
|
+
value=value,
|
|
134
|
+
timestamp=timestamp,
|
|
135
|
+
metadata=metadata or {},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
140
|
+
# Batch collection helper
|
|
141
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def collect_batch(
|
|
145
|
+
agent: Agent,
|
|
146
|
+
records: list[tuple[TelemetryCategory, float]],
|
|
147
|
+
) -> list[TelemetryRecord]:
|
|
148
|
+
"""
|
|
149
|
+
Collect multiple telemetry records in one call.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
agent: The agent to collect from.
|
|
153
|
+
records: List of (category, value) tuples.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of TelemetryRecord, one per input tuple.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
AgentNotRunning: If agent.status != AgentStatus.RUNNING.
|
|
160
|
+
InvalidValue: If any value is NaN or Inf.
|
|
161
|
+
"""
|
|
162
|
+
from datetime import datetime, timezone
|
|
163
|
+
|
|
164
|
+
results: list[TelemetryRecord] = []
|
|
165
|
+
previous: Optional[str] = None
|
|
166
|
+
|
|
167
|
+
for i, (category, value) in enumerate(records):
|
|
168
|
+
# Use microsecond-precision timestamps to ensure monotonicity
|
|
169
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
170
|
+
# Small artificial delay to ensure monotonicity
|
|
171
|
+
if previous is not None:
|
|
172
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
173
|
+
record = collect(
|
|
174
|
+
agent=agent,
|
|
175
|
+
category=category,
|
|
176
|
+
value=value,
|
|
177
|
+
timestamp=ts,
|
|
178
|
+
previous_timestamp=previous,
|
|
179
|
+
)
|
|
180
|
+
results.append(record)
|
|
181
|
+
previous = ts
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
187
|
+
# Telemetry log helper
|
|
188
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TelemetryLog:
|
|
192
|
+
"""
|
|
193
|
+
An in-memory log of telemetry records for an agent.
|
|
194
|
+
|
|
195
|
+
Used by the detection layer (Layer 2) and baseline computation.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self) -> None:
|
|
199
|
+
self._records: dict[str, list[TelemetryRecord]] = {}
|
|
200
|
+
|
|
201
|
+
def append(self, record: TelemetryRecord) -> None:
|
|
202
|
+
"""Append a record to the log (grouped by agent_id)."""
|
|
203
|
+
if record.agent_id not in self._records:
|
|
204
|
+
self._records[record.agent_id] = []
|
|
205
|
+
self._records[record.agent_id].append(record)
|
|
206
|
+
|
|
207
|
+
def get(self, agent_id: str) -> list[TelemetryRecord]:
|
|
208
|
+
"""Get all records for an agent."""
|
|
209
|
+
return list(self._records.get(agent_id, []))
|
|
210
|
+
|
|
211
|
+
def get_by_category(
|
|
212
|
+
self, agent_id: str, category: TelemetryCategory
|
|
213
|
+
) -> list[TelemetryRecord]:
|
|
214
|
+
"""Get all records for an agent and category."""
|
|
215
|
+
return [
|
|
216
|
+
r for r in self._records.get(agent_id, []) if r.category == category
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
def last_timestamp(self, agent_id: str) -> Optional[str]:
|
|
220
|
+
"""Get the last timestamp for an agent (for monotonicity check)."""
|
|
221
|
+
records = self._records.get(agent_id, [])
|
|
222
|
+
return records[-1].timestamp if records else None
|
|
223
|
+
|
|
224
|
+
def count(self, agent_id: str) -> int:
|
|
225
|
+
"""Count records for an agent."""
|
|
226
|
+
return len(self._records.get(agent_id, []))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
230
|
+
# Module-level singleton (optional)
|
|
231
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
default_telemetry_log = TelemetryLog()
|
|
235
|
+
"""Default telemetry log (can be replaced per-test)."""
|
aetherruntime/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""AetherRuntime — Recovery layer (Layer 5)."""
|
|
2
|
+
|
|
3
|
+
from .snapshot import (
|
|
4
|
+
create_snapshot,
|
|
5
|
+
verify_snapshot,
|
|
6
|
+
SnapshotRegistry,
|
|
7
|
+
SnapshotNotFound as SnapshotSnapshotNotFound,
|
|
8
|
+
HashMismatch,
|
|
9
|
+
get_snapshot_hash,
|
|
10
|
+
compare_snapshots,
|
|
11
|
+
)
|
|
12
|
+
from .replay import (
|
|
13
|
+
recover,
|
|
14
|
+
RecoveryRegistry,
|
|
15
|
+
RecoveryStats,
|
|
16
|
+
AgentNotHalted,
|
|
17
|
+
SnapshotNotFound as ReplaySnapshotNotFound,
|
|
18
|
+
UnauthorizedReauthorizer,
|
|
19
|
+
VerificationFailed,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"create_snapshot",
|
|
24
|
+
"verify_snapshot",
|
|
25
|
+
"SnapshotRegistry",
|
|
26
|
+
"SnapshotSnapshotNotFound",
|
|
27
|
+
"HashMismatch",
|
|
28
|
+
"get_snapshot_hash",
|
|
29
|
+
"compare_snapshots",
|
|
30
|
+
"recover",
|
|
31
|
+
"RecoveryRegistry",
|
|
32
|
+
"RecoveryStats",
|
|
33
|
+
"AgentNotHalted",
|
|
34
|
+
"ReplaySnapshotNotFound",
|
|
35
|
+
"UnauthorizedReauthorizer",
|
|
36
|
+
"VerificationFailed",
|
|
37
|
+
]
|