brawny 0.1.13__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 (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,364 @@
1
+ """Function caller classes for contract interactions.
2
+
3
+ Provides FunctionCaller, OverloadedFunction, and ExplicitFunctionCaller classes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from eth_abi import encode as abi_encode, decode as abi_decode
11
+
12
+ from brawny._context import resolve_block_identifier
13
+ from brawny.alerts.encoded_call import EncodedCall, FunctionABI, ReturnValue
14
+ from brawny.alerts.errors import (
15
+ AmbiguousOverloadError,
16
+ ContractCallError,
17
+ OverloadMatchError,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from brawny.alerts.contracts import ContractHandle
22
+ from brawny.jobs.base import TxReceipt
23
+
24
+
25
+ class FunctionCaller:
26
+ """Callable wrapper for contract functions with Brownie-style interface.
27
+
28
+ Behavior varies by function state mutability:
29
+ - View/pure functions: __call__ executes eth_call, returns decoded value
30
+ - State-changing functions:
31
+ - With {"from": ...} as last arg: broadcasts transaction, returns receipt
32
+ - Without tx_params: returns EncodedCall (calldata with modifiers)
33
+
34
+ Methods:
35
+ - encode_input(*args): Get calldata without executing
36
+ - call(*args): Force eth_call simulation
37
+ - transact(*args, tx_params): Broadcast transaction
38
+
39
+ Usage (Brownie-style):
40
+ token.balanceOf(owner) # View - returns value
41
+ token.transfer(to, amount, {"from": accounts[0]}) # Broadcasts, returns receipt
42
+ token.transfer(to, amount) # Returns EncodedCall (calldata)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ contract: "ContractHandle",
48
+ function_abi: FunctionABI,
49
+ ) -> None:
50
+ self._contract = contract
51
+ self._abi = function_abi
52
+
53
+ def __call__(self, *args: Any, block_identifier: int | str | None = None) -> Any:
54
+ """Call the function with automatic state mutability handling.
55
+
56
+ For view/pure functions: executes eth_call and returns decoded result.
57
+ For state-changing functions:
58
+ - With tx_params dict (Brownie-style): broadcasts and returns receipt
59
+ - Without tx_params: returns EncodedCall for deferred execution
60
+
61
+ Args:
62
+ *args: Function arguments. For state-changing functions, may include
63
+ tx_params dict as last argument (Brownie-style).
64
+ block_identifier: Optional block number/tag override for view calls.
65
+ If None, uses handle's block or "latest".
66
+
67
+ Usage:
68
+ # View function - returns value directly
69
+ decimals = token.decimals() # 18
70
+ decimals = token.decimals(block_identifier=21000000) # at specific block
71
+
72
+ # State-changing function - Brownie-style (broadcasts immediately)
73
+ receipt = token.transfer(to, amount, {"from": accounts[0]})
74
+
75
+ # State-changing function - returns EncodedCall (for calldata)
76
+ calldata = vault.harvest() # "0x4641257d"
77
+
78
+ # EncodedCall can be used as calldata or with modifiers
79
+ result = vault.harvest().call() # Simulate
80
+ receipt = vault.harvest().transact({"from": "worker"}) # Broadcast
81
+ """
82
+ if self._abi.is_state_changing:
83
+ # Check if last arg is tx_params dict (Brownie-style immediate broadcast)
84
+ if args and isinstance(args[-1], dict) and "from" in args[-1]:
85
+ tx_params = args[-1]
86
+ func_args = args[:-1]
87
+ calldata = self._encode_calldata(*func_args)
88
+ return self._contract._transact_with_calldata(calldata, tx_params, self._abi)
89
+
90
+ # No tx_params - return EncodedCall for calldata or deferred .transact()
91
+ calldata = self._encode_calldata(*args)
92
+ return EncodedCall(calldata, self._contract, self._abi)
93
+
94
+ # View/pure functions execute immediately
95
+ return self._execute_call(*args, block_identifier=block_identifier)
96
+
97
+ def encode_input(self, *args: Any) -> str:
98
+ """Encode function calldata without executing.
99
+
100
+ Works for any function regardless of state mutability.
101
+
102
+ Usage:
103
+ data = vault.harvest.encode_input()
104
+ data = token.transfer.encode_input(recipient, amount)
105
+
106
+ Returns:
107
+ Hex-encoded calldata string
108
+ """
109
+ return self._encode_calldata(*args)
110
+
111
+ def call(self, *args: Any, block_identifier: int | str | None = None) -> Any:
112
+ """Force eth_call simulation and return decoded result.
113
+
114
+ Works for any function regardless of state mutability.
115
+ Useful for simulating state-changing functions without broadcasting.
116
+
117
+ Args:
118
+ *args: Function arguments
119
+ block_identifier: Optional block number/tag override.
120
+ If None, uses handle's block or "latest".
121
+
122
+ Usage:
123
+ # Simulate state-changing function
124
+ result = vault.harvest.call()
125
+
126
+ # Also works for view functions (same as direct call)
127
+ decimals = token.decimals.call()
128
+
129
+ # Query at specific block
130
+ decimals = token.decimals.call(block_identifier=21000000)
131
+
132
+ Returns:
133
+ Decoded return value from the function
134
+ """
135
+ return self._execute_call(*args, block_identifier=block_identifier)
136
+
137
+ def transact(self, *args: Any) -> "TxReceipt":
138
+ """Broadcast the transaction and wait for receipt.
139
+
140
+ Only works inside a @broadcast decorated function.
141
+ Transaction params dict must be the last argument.
142
+
143
+ Usage:
144
+ # No-arg function
145
+ receipt = vault.harvest.transact({"from": "yearn-worker"})
146
+
147
+ # With function args (tx_params is last)
148
+ receipt = token.transfer.transact(recipient, amount, {"from": "worker"})
149
+
150
+ Args:
151
+ *args: Function arguments, with tx_params dict as last arg
152
+
153
+ Returns:
154
+ Transaction receipt after confirmation
155
+
156
+ Raises:
157
+ BroadcastNotAllowedError: If not in @broadcast context
158
+ ValueError: If tx_params dict not provided
159
+ """
160
+ # Extract tx_params from last arg
161
+ if not args or not isinstance(args[-1], dict):
162
+ raise ValueError(
163
+ f"{self._abi.name}.transact() requires tx_params dict as last argument. "
164
+ f"Example: vault.{self._abi.name}.transact({{\"from\": \"signer\"}})"
165
+ )
166
+
167
+ tx_params = args[-1]
168
+ func_args = args[:-1]
169
+
170
+ # Encode calldata and delegate to contract helper
171
+ calldata = self._encode_calldata(*func_args)
172
+ return self._contract._transact_with_calldata(calldata, tx_params, self._abi)
173
+
174
+ def _execute_call(self, *args: Any, block_identifier: int | str | None = None) -> Any:
175
+ """Execute eth_call and decode result.
176
+
177
+ Args:
178
+ *args: Function arguments
179
+ block_identifier: Optional block override. If None, uses handle's block or "latest".
180
+ """
181
+ rpc = self._contract._system.rpc
182
+
183
+ # Encode call data
184
+ calldata = self._encode_calldata(*args)
185
+
186
+ # Build tx params for eth_call
187
+ tx_params = {
188
+ "to": self._contract.address,
189
+ "data": calldata,
190
+ }
191
+
192
+ # Resolve block using centralized 4-level precedence:
193
+ # 1. Explicit param 2. Handle's block 3. Check scope pin 4. "latest"
194
+ block_id = resolve_block_identifier(
195
+ explicit=block_identifier,
196
+ handle_block=self._contract._block_identifier,
197
+ )
198
+
199
+ # Execute call with block pinning
200
+ try:
201
+ result = rpc.eth_call(tx_params, block_identifier=block_id)
202
+ except Exception as e:
203
+ raise ContractCallError(
204
+ function_name=self._abi.name,
205
+ address=self._contract.address,
206
+ reason=str(e),
207
+ block_identifier=self._contract._block_identifier,
208
+ signature=self._abi.signature,
209
+ job_id=self._contract._job_id,
210
+ hook=self._contract._hook,
211
+ )
212
+
213
+ # Convert result to hex string if bytes
214
+ if isinstance(result, bytes):
215
+ result = "0x" + result.hex()
216
+
217
+ # Decode result
218
+ return self._decode_result(result)
219
+
220
+ def _encode_calldata(self, *args: Any) -> str:
221
+ """Encode function call data."""
222
+ if not self._abi.inputs:
223
+ return "0x" + self._abi.selector.hex()
224
+
225
+ # Convert floats to ints (supports scientific notation like 1e18)
226
+ converted_args = [int(a) if isinstance(a, float) else a for a in args]
227
+ types = [inp["type"] for inp in self._abi.inputs]
228
+ encoded_args = abi_encode(types, converted_args)
229
+ return "0x" + self._abi.selector.hex() + encoded_args.hex()
230
+
231
+ def _decode_result(self, result: str) -> Any:
232
+ """Decode function return value with Brownie-compatible wrapping."""
233
+ if not self._abi.outputs:
234
+ return None
235
+
236
+ if result == "0x" or not result:
237
+ return None
238
+
239
+ if isinstance(result, str) and result.startswith("0x0x"):
240
+ result = "0x" + result[4:]
241
+
242
+ # Remove 0x prefix
243
+ data = bytes.fromhex(result[2:] if result.startswith("0x") else result)
244
+ if not data:
245
+ return None
246
+
247
+ types = [out["type"] for out in self._abi.outputs]
248
+ decoded = abi_decode(types, data)
249
+
250
+ # Single return value
251
+ if len(decoded) == 1:
252
+ # If it's a struct, wrap it so nested fields are accessible
253
+ if self._abi.outputs[0].get("components"):
254
+ return ReturnValue(decoded, self._abi.outputs)[0]
255
+ return decoded[0]
256
+
257
+ # Multiple return values: wrap in ReturnValue for named access
258
+ return ReturnValue(decoded, self._abi.outputs)
259
+
260
+
261
+ class OverloadedFunction:
262
+ """Dispatcher for overloaded contract functions.
263
+
264
+ Resolves the correct overload based on argument count and delegates
265
+ to FunctionCaller. If multiple overloads match, raises AmbiguousOverloadError.
266
+ """
267
+
268
+ def __init__(self, contract: "ContractHandle", overloads: list[FunctionABI]) -> None:
269
+ self._contract = contract
270
+ self._overloads = overloads
271
+
272
+ def __call__(self, *args: Any) -> Any:
273
+ # Check if last arg is tx_params dict (Brownie-style)
274
+ if args and isinstance(args[-1], dict) and "from" in args[-1]:
275
+ tx_params = args[-1]
276
+ func_args = args[:-1]
277
+ caller = self._resolve(func_args) # Resolve based on func_args count
278
+ return caller(*func_args, tx_params) # FunctionCaller handles tx_params
279
+ else:
280
+ caller = self._resolve(args)
281
+ return caller(*args)
282
+
283
+ def call(self, *args: Any) -> Any:
284
+ caller = self._resolve(args)
285
+ return caller.call(*args)
286
+
287
+ def encode_input(self, *args: Any) -> str:
288
+ caller = self._resolve(args)
289
+ return caller.encode_input(*args)
290
+
291
+ def transact(self, *args: Any) -> "TxReceipt":
292
+ caller, func_args = self._resolve_for_transact(args)
293
+ return caller.transact(*func_args)
294
+
295
+ def _resolve(self, args: tuple[Any, ...]) -> FunctionCaller:
296
+ matches = [f for f in self._overloads if len(f.inputs) == len(args)]
297
+ if not matches:
298
+ candidates = [f.signature for f in self._overloads]
299
+ raise OverloadMatchError(
300
+ self._overloads[0].name,
301
+ len(args),
302
+ candidates,
303
+ )
304
+ if len(matches) > 1:
305
+ candidates = [f.signature for f in matches]
306
+ raise AmbiguousOverloadError(
307
+ self._overloads[0].name,
308
+ len(args),
309
+ candidates,
310
+ )
311
+ return FunctionCaller(self._contract, matches[0])
312
+
313
+ def _resolve_for_transact(
314
+ self, args: tuple[Any, ...]
315
+ ) -> tuple[FunctionCaller, tuple[Any, ...]]:
316
+ if not args or not isinstance(args[-1], dict):
317
+ raise ValueError(
318
+ f"{self._overloads[0].name}.transact() requires tx_params dict as last argument."
319
+ )
320
+ func_args = args[:-1]
321
+ caller = self._resolve(func_args)
322
+ return caller, args
323
+
324
+
325
+ class ExplicitFunctionCaller:
326
+ """Explicit function caller for overloaded functions.
327
+
328
+ Usage:
329
+ token.fn("balanceOf(address)").call(owner)
330
+ token.fn("transfer(address,uint256)").transact(to, amount, {"from": "worker"})
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ contract: "ContractHandle",
336
+ function_abi: FunctionABI,
337
+ ) -> None:
338
+ self._contract = contract
339
+ self._abi = function_abi
340
+
341
+ def call(self, *args: Any) -> Any:
342
+ """Execute eth_call and return decoded result.
343
+
344
+ Works for both view and state-changing functions (simulates the call).
345
+ """
346
+ caller = FunctionCaller(self._contract, self._abi)
347
+ return caller._execute_call(*args)
348
+
349
+ def transact(self, *args: Any) -> "TxReceipt":
350
+ """Broadcast the transaction and wait for receipt.
351
+
352
+ Only works inside a @broadcast decorated function.
353
+ Transaction params dict must be the last argument.
354
+ """
355
+ caller = FunctionCaller(self._contract, self._abi)
356
+ return caller.transact(*args)
357
+
358
+ def encode_input(self, *args: Any) -> str:
359
+ """Encode function call data without executing.
360
+
361
+ Returns hex-encoded calldata.
362
+ """
363
+ caller = FunctionCaller(self._contract, self._abi)
364
+ return caller._encode_calldata(*args)
@@ -0,0 +1,185 @@
1
+ """Daemon health alerts with fingerprint-based deduplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import threading
7
+ from datetime import datetime, timedelta
8
+ from typing import Any, Callable, Literal
9
+
10
+ from brawny.logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ DEFAULT_COOLDOWN_SECONDS = 1800
15
+ MAX_FIELD_LEN = 200
16
+
17
+ _last_fired: dict[str, datetime] = {}
18
+ _first_seen: dict[str, datetime] = {}
19
+ _suppressed_count: dict[str, int] = {}
20
+ _lock = threading.Lock()
21
+
22
+
23
+ def _fingerprint(
24
+ component: str,
25
+ exc_type: str,
26
+ chain_id: int,
27
+ db_dialect: str | None = None,
28
+ fingerprint_key: str | None = None,
29
+ ) -> str:
30
+ """Compute stable fingerprint for deduplication.
31
+
32
+ Default: component + exc_type + chain_id + db_dialect (message excluded for stability).
33
+ Override with fingerprint_key for explicit grouping (e.g., invariant names).
34
+ """
35
+ if fingerprint_key:
36
+ key = f"{fingerprint_key}:{chain_id}:{db_dialect or 'unknown'}"
37
+ else:
38
+ key = f"{component}:{exc_type}:{chain_id}:{db_dialect or 'unknown'}"
39
+ return hashlib.sha1(key.encode()).hexdigest()[:12]
40
+
41
+
42
+ def health_alert(
43
+ *,
44
+ component: str,
45
+ chain_id: int,
46
+ error: Exception | str,
47
+ level: Literal["warning", "error", "critical"] = "error",
48
+ job_id: str | None = None,
49
+ intent_id: str | None = None,
50
+ claim_token: str | None = None,
51
+ status: str | None = None,
52
+ action: str | None = None,
53
+ db_dialect: str | None = None,
54
+ fingerprint_key: str | None = None,
55
+ force_send: bool = False,
56
+ send_fn: Callable[..., None] | None = None,
57
+ health_chat_id: str | None = None,
58
+ cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS,
59
+ ) -> None:
60
+ """Send a daemon health alert with deduplication.
61
+
62
+ First occurrence: sends immediately (if level >= error).
63
+ Within cooldown: suppressed, count incremented.
64
+ After cooldown: sends summary with suppressed count + duration.
65
+ Warnings are logged only, never sent to Telegram.
66
+
67
+ Args:
68
+ component: Component identifier (e.g., "brawny.tx.executor")
69
+ chain_id: Chain ID for context
70
+ error: Exception or error message
71
+ level: Severity level (warning, error, critical)
72
+ job_id: Optional job identifier
73
+ intent_id: Optional intent identifier
74
+ claim_token: Optional claim token for debugging stuckness
75
+ status: Optional intent status for debugging
76
+ action: Suggested remediation action
77
+ db_dialect: Database dialect for fingerprinting
78
+ fingerprint_key: Override default fingerprint for explicit grouping
79
+ force_send: Bypass deduplication entirely (e.g., startup alerts)
80
+ send_fn: Function to send alerts (e.g., alerts.send.send_health)
81
+ health_chat_id: Telegram chat ID for health alerts
82
+ cooldown_seconds: Deduplication window in seconds
83
+ """
84
+ exc_type = type(error).__name__ if isinstance(error, Exception) else "Error"
85
+ message = str(error)[:MAX_FIELD_LEN]
86
+ fp = _fingerprint(component, exc_type, chain_id, db_dialect, fingerprint_key)
87
+
88
+ now = datetime.utcnow()
89
+ should_send = False
90
+ suppressed = 0
91
+ first_seen = now
92
+
93
+ if force_send:
94
+ should_send = True
95
+ else:
96
+ with _lock:
97
+ last = _last_fired.get(fp)
98
+ if last is None:
99
+ # First occurrence
100
+ should_send = True
101
+ _last_fired[fp] = now
102
+ _first_seen[fp] = now
103
+ _suppressed_count[fp] = 0
104
+ elif now - last > timedelta(seconds=cooldown_seconds):
105
+ # Cooldown expired, send summary
106
+ should_send = True
107
+ suppressed = _suppressed_count.get(fp, 0)
108
+ first_seen = _first_seen.get(fp, now)
109
+ _last_fired[fp] = now
110
+ _first_seen[fp] = now # Reset for next incident window
111
+ _suppressed_count[fp] = 0
112
+ else:
113
+ # Within cooldown, suppress
114
+ _suppressed_count[fp] = _suppressed_count.get(fp, 0) + 1
115
+
116
+ # Always log (use appropriate log level)
117
+ if level == "critical":
118
+ log_fn = logger.critical
119
+ elif level == "warning":
120
+ log_fn = logger.warning
121
+ else:
122
+ log_fn = logger.error
123
+
124
+ log_fn(
125
+ "daemon.health_alert",
126
+ component=component,
127
+ chain_id=chain_id,
128
+ error=message,
129
+ exc_type=exc_type,
130
+ level=level,
131
+ job_id=job_id,
132
+ intent_id=intent_id,
133
+ claim_token=claim_token,
134
+ status=status,
135
+ fingerprint=fp,
136
+ suppressed=not should_send,
137
+ )
138
+
139
+ # Warnings are logged only, never sent to Telegram
140
+ if level == "warning":
141
+ return
142
+
143
+ if not should_send:
144
+ return
145
+
146
+ if send_fn is None or health_chat_id is None:
147
+ return
148
+
149
+ # Build message (cap all fields)
150
+ lines = ["⚠️ Brawny Health Alert" if level == "error" else "🔴 CRITICAL Health Alert"]
151
+ lines.append(f"chain_id={chain_id}")
152
+ if job_id:
153
+ lines.append(f"job={job_id[:MAX_FIELD_LEN]}")
154
+ if intent_id:
155
+ lines.append(f"intent={intent_id[:12]}...")
156
+ if claim_token:
157
+ lines.append(f"claim_token={claim_token[:12]}...")
158
+ if status:
159
+ lines.append(f"status={status}")
160
+ lines.append(f"{exc_type}: {message}")
161
+ if suppressed > 0:
162
+ duration_seconds = (now - first_seen).total_seconds()
163
+ duration_str = f"{duration_seconds / 60:.0f}m" if duration_seconds >= 60 else f"{duration_seconds:.0f}s"
164
+ lines.append(f"(suppressed {suppressed}x over {duration_str})")
165
+ if action:
166
+ lines.append(f"Action: {action[:MAX_FIELD_LEN]}")
167
+
168
+ try:
169
+ send_fn(chat_id=health_chat_id, text="\n".join(lines))
170
+ except Exception as e:
171
+ logger.warning("health_alert.send_failed", error=str(e))
172
+
173
+
174
+ def cleanup_stale_fingerprints(cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS) -> int:
175
+ """Remove fingerprints older than 2x cooldown. Returns count removed."""
176
+ cutoff = datetime.utcnow() - timedelta(seconds=cooldown_seconds * 2)
177
+ removed = 0
178
+ with _lock:
179
+ stale = [fp for fp, ts in _last_fired.items() if ts < cutoff]
180
+ for fp in stale:
181
+ _last_fired.pop(fp, None)
182
+ _first_seen.pop(fp, None)
183
+ _suppressed_count.pop(fp, None)
184
+ removed += 1
185
+ return removed
@@ -0,0 +1,118 @@
1
+ """Alert routing resolution.
2
+
3
+ Resolves named targets to chat IDs for Telegram alerts.
4
+ Could be extended for webhooks later.
5
+
6
+ Policy: Startup fails hard, runtime logs + drops.
7
+ - Startup validation catches typos during normal deployment
8
+ - Runtime unknown names log error and are skipped (not raised)
9
+ - This prevents hot-edited typos from crashing hook execution
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from brawny.logging import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ def is_chat_id(s: str) -> bool:
20
+ """Check if string looks like a raw Telegram chat ID.
21
+
22
+ Handles:
23
+ - Supergroups/channels: -100...
24
+ - Basic groups: negative ints -12345
25
+ - User IDs: positive ints
26
+ """
27
+ return s.lstrip("-").isdigit()
28
+
29
+
30
+ def resolve_targets(
31
+ target: str | list[str] | None,
32
+ chats: dict[str, str],
33
+ default: list[str],
34
+ *,
35
+ job_id: str | None = None,
36
+ ) -> list[str]:
37
+ """Resolve target(s) to deduplicated list of chat IDs.
38
+
39
+ Policy: Startup fails hard, runtime logs + drops.
40
+ - Startup validation catches typos during normal deployment
41
+ - Runtime unknown names log error and are skipped (not raised)
42
+ - This prevents hot-edited typos from crashing hook execution
43
+
44
+ Args:
45
+ target: Chat name, raw ID, list of either, or None
46
+ chats: Name -> chat_id mapping from config
47
+ default: Default chat names/IDs if target is None
48
+ job_id: Optional job ID for logging context
49
+
50
+ Returns:
51
+ Deduplicated list of resolved chat IDs (preserves order).
52
+ Unknown names are logged and skipped, not raised.
53
+ """
54
+ if target is None:
55
+ targets = default
56
+ elif isinstance(target, str):
57
+ targets = [target]
58
+ else:
59
+ targets = target
60
+
61
+ # Resolve names to IDs, dedupe while preserving order
62
+ seen: set[str] = set()
63
+ result: list[str] = []
64
+
65
+ for t in targets:
66
+ t = t.strip()
67
+ if not t:
68
+ continue
69
+
70
+ # Resolve: raw ID passes through, named chat looks up, unknown logs + skips
71
+ if is_chat_id(t):
72
+ chat_id = t
73
+ elif t in chats:
74
+ chat_id = chats[t]
75
+ else:
76
+ # Log and skip unknown names (don't crash hooks at runtime)
77
+ logger.error(
78
+ "alert.routing.unknown_target",
79
+ target=t,
80
+ job_id=job_id,
81
+ valid_names=sorted(chats.keys()),
82
+ )
83
+ continue
84
+
85
+ if chat_id not in seen:
86
+ seen.add(chat_id)
87
+ result.append(chat_id)
88
+
89
+ return result
90
+
91
+
92
+ def validate_targets(
93
+ target: str | list[str] | None,
94
+ valid_names: set[str],
95
+ ) -> list[str]:
96
+ """Validate that all non-ID targets are valid chat names.
97
+
98
+ Used at startup for hard failure on unknown names.
99
+
100
+ Args:
101
+ target: Chat name, raw ID, list of either, or None
102
+ valid_names: Set of valid chat names from config
103
+
104
+ Returns:
105
+ List of invalid names (empty if all valid)
106
+ """
107
+ if target is None:
108
+ return []
109
+
110
+ targets = [target] if isinstance(target, str) else target
111
+ invalid: list[str] = []
112
+
113
+ for t in targets:
114
+ t = t.strip()
115
+ if t and not is_chat_id(t) and t not in valid_names:
116
+ invalid.append(t)
117
+
118
+ return invalid