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,253 @@
1
+ """Network management CLI commands (Brownie-compatible).
2
+
3
+ Usage:
4
+ brawny networks list
5
+ brawny networks add <type> <id> <settings...>
6
+ brawny networks delete <id>
7
+
8
+ These commands manage ~/.brawny/network-config.yaml (Brownie-compatible format).
9
+ This is separate from the project-level config.yaml networks section.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+ import yaml
19
+
20
+
21
+ def _get_config_path() -> Path:
22
+ """Get user network config path."""
23
+ return Path.home() / ".brawny" / "network-config.yaml"
24
+
25
+
26
+ def _load_config() -> dict[str, Any]:
27
+ """Load user network config."""
28
+ path = _get_config_path()
29
+ if path.exists():
30
+ with open(path) as f:
31
+ return yaml.safe_load(f) or {}
32
+ return {"live": [], "development": []}
33
+
34
+
35
+ def _save_config(config: dict[str, Any]) -> None:
36
+ """Save user network config."""
37
+ path = _get_config_path()
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ with open(path, "w") as f:
40
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
41
+
42
+
43
+ @click.group("networks")
44
+ def networks_cli() -> None:
45
+ """Manage Brownie-compatible network configurations.
46
+
47
+ These networks are stored in ~/.brawny/network-config.yaml and used
48
+ by `network.connect()` in scripts and console.
49
+ """
50
+ pass
51
+
52
+
53
+ def _redact_api_keys(url: str) -> str:
54
+ """Redact API keys from URLs for safe display.
55
+
56
+ Matches common patterns:
57
+ - /v2/abc123... → /v2/abc1...
58
+ - /v3/abc123... → /v3/abc1...
59
+ - ?apikey=... → ?apikey=...
60
+ """
61
+ import re
62
+ # Redact Alchemy/Infura-style path keys (show first 4 chars)
63
+ url = re.sub(r"(/v[23]/)([a-zA-Z0-9_-]{4})([a-zA-Z0-9_-]+)", r"\1\2...", url)
64
+ # Redact query param API keys
65
+ url = re.sub(r"([\?&]api[_-]?key=)([a-zA-Z0-9_-]{4})([a-zA-Z0-9_-]+)", r"\1\2...", url, flags=re.I)
66
+ return url
67
+
68
+
69
+ @networks_cli.command("list")
70
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed info (redacts API keys)")
71
+ def list_networks(verbose: bool) -> None:
72
+ """List available networks."""
73
+ from brawny.networks.config import load_networks
74
+
75
+ networks = load_networks()
76
+
77
+ live = [(n.id, n) for n in networks.values() if not n.is_development]
78
+ dev = [(n.id, n) for n in networks.values() if n.is_development]
79
+
80
+ click.echo("\nLive Networks:")
81
+ if not live:
82
+ click.echo(" (none configured)")
83
+ for net_id, net in sorted(live):
84
+ if verbose:
85
+ # Redact API keys in displayed hosts for security
86
+ redacted_hosts = [_redact_api_keys(h) for h in net.hosts[:2]]
87
+ hosts_str = ", ".join(redacted_hosts)
88
+ if len(net.hosts) > 2:
89
+ hosts_str += f" (+{len(net.hosts) - 2} more)"
90
+ click.echo(f" {net_id}: chainid={net.chainid} hosts=[{hosts_str}]")
91
+ else:
92
+ click.echo(f" - {net_id} (chainid: {net.chainid})")
93
+
94
+ click.echo("\nDevelopment Networks:")
95
+ for net_id, net in sorted(dev):
96
+ fork_info = " (fork)" if net.is_fork else ""
97
+ if verbose:
98
+ click.echo(f" {net_id}: cmd={net.cmd} port={net.cmd_settings.get('port', 8545)}{fork_info}")
99
+ else:
100
+ click.echo(f" - {net_id}{fork_info}")
101
+
102
+
103
+ @networks_cli.command("add")
104
+ @click.argument("network_type", type=click.Choice(["live", "development"]))
105
+ @click.argument("network_id")
106
+ @click.argument("settings", nargs=-1)
107
+ def add_network(network_type: str, network_id: str, settings: tuple[str, ...]) -> None:
108
+ """Add a new network to ~/.brawny/network-config.yaml.
109
+
110
+ Examples:
111
+
112
+ brawny networks add live mainnet-custom host=https://... chainid=1
113
+
114
+ # Multiple hosts for failover (RPC pool):
115
+ brawny networks add live mainnet host=https://eth.llamarpc.com host=https://eth-mainnet.g.alchemy.com/v2/KEY chainid=1
116
+
117
+ brawny networks add development my-fork cmd=anvil port=9545 fork=mainnet
118
+ """
119
+ from brawny.networks.config import load_networks
120
+
121
+ # Check if network ID already exists
122
+ existing = load_networks()
123
+ if network_id in existing:
124
+ raise click.ClickException(
125
+ f"Network '{network_id}' already exists. "
126
+ f"Delete it first with: brawny networks delete {network_id}"
127
+ )
128
+
129
+ config = _load_config()
130
+
131
+ # Parse key=value settings - collect repeated keys as lists
132
+ params: dict[str, Any] = {}
133
+ multi_keys: dict[str, list[str]] = {} # For repeated keys like host=...
134
+
135
+ for s in settings:
136
+ if "=" not in s:
137
+ raise click.ClickException(f"Invalid: {s}. Use key=value format.")
138
+ key, value_str = s.split("=", 1)
139
+
140
+ # Special handling for 'host' - can be repeated for multi-host
141
+ if key == "host":
142
+ multi_keys.setdefault("host", []).append(value_str)
143
+ continue
144
+
145
+ # Parse integers and floats for other keys
146
+ value: Any = value_str
147
+ try:
148
+ value = int(value_str)
149
+ except ValueError:
150
+ try:
151
+ value = float(value_str)
152
+ except ValueError:
153
+ pass
154
+ params[key] = value
155
+
156
+ # Convert multi-host to single or list as needed
157
+ if "host" in multi_keys:
158
+ hosts = multi_keys["host"]
159
+ params["host"] = hosts if len(hosts) > 1 else hosts[0]
160
+
161
+ if network_type == "live":
162
+ if "host" not in params:
163
+ raise click.ClickException("Live networks require 'host'")
164
+ if "chainid" not in params:
165
+ raise click.ClickException("Live networks require 'chainid'")
166
+
167
+ # Ensure live section exists with at least one group
168
+ if "live" not in config or not config["live"]:
169
+ config["live"] = [{"name": "Custom", "networks": []}]
170
+
171
+ # Find Custom group or use first group
172
+ group = config["live"][0]
173
+ for g in config["live"]:
174
+ if g.get("name") == "Custom":
175
+ group = g
176
+ break
177
+
178
+ net_config = {"id": network_id, **params}
179
+ group.setdefault("networks", []).append(net_config)
180
+
181
+ else: # development
182
+ if "cmd" not in params:
183
+ raise click.ClickException("Development networks require 'cmd'")
184
+
185
+ # Separate cmd_settings from top-level params
186
+ cmd_keys = {"port", "fork", "fork_block", "accounts", "balance", "chain_id", "mnemonic", "block_time"}
187
+ cmd_settings = {k: params.pop(k) for k in list(params.keys()) if k in cmd_keys}
188
+
189
+ net_config: dict[str, Any] = {
190
+ "id": network_id,
191
+ "host": params.pop("host", "http://127.0.0.1"),
192
+ "cmd": params.pop("cmd"),
193
+ **params,
194
+ }
195
+ if cmd_settings:
196
+ net_config["cmd_settings"] = cmd_settings
197
+
198
+ config.setdefault("development", []).append(net_config)
199
+
200
+ _save_config(config)
201
+ click.echo(f"Added {network_type} network: {network_id}")
202
+
203
+
204
+ @networks_cli.command("delete")
205
+ @click.argument("network_id")
206
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation")
207
+ def delete_network(network_id: str, force: bool) -> None:
208
+ """Delete a network from user config."""
209
+ # Don't allow deleting built-in defaults
210
+ builtin = {"development", "mainnet-fork"}
211
+ if network_id in builtin:
212
+ raise click.ClickException(
213
+ f"Cannot delete built-in network '{network_id}'. "
214
+ "You can override it by adding a network with the same ID."
215
+ )
216
+
217
+ config = _load_config()
218
+ found = False
219
+
220
+ # Check live networks
221
+ for group in config.get("live", []):
222
+ networks = group.get("networks", [])
223
+ for i, net in enumerate(networks):
224
+ if net["id"] == network_id:
225
+ if not force:
226
+ click.confirm(f"Delete '{network_id}'?", abort=True)
227
+ networks.pop(i)
228
+ found = True
229
+ break
230
+ if found:
231
+ break
232
+
233
+ # Check development networks
234
+ if not found:
235
+ dev = config.get("development", [])
236
+ for i, net in enumerate(dev):
237
+ if net["id"] == network_id:
238
+ if not force:
239
+ click.confirm(f"Delete '{network_id}'?", abort=True)
240
+ dev.pop(i)
241
+ found = True
242
+ break
243
+
244
+ if not found:
245
+ raise click.ClickException(f"Network '{network_id}' not found in user config")
246
+
247
+ _save_config(config)
248
+ click.echo(f"Deleted: {network_id}")
249
+
250
+
251
+ def register(main) -> None:
252
+ """Register networks command with main CLI."""
253
+ main.add_command(networks_cli)
@@ -0,0 +1,249 @@
1
+ """Runner command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+ import click
9
+
10
+
11
+ @click.command()
12
+ @click.option("--once", is_flag=True, help="Process once and exit")
13
+ @click.option("--workers", type=int, help="Worker count override")
14
+ @click.option("--dry-run", is_flag=True, help="Create intents but don't execute")
15
+ @click.option(
16
+ "--jobs-module",
17
+ "jobs_modules",
18
+ multiple=True,
19
+ help="Additional job module(s) to load",
20
+ )
21
+ @click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
22
+ @click.option(
23
+ "--no-strict",
24
+ is_flag=True,
25
+ help="Don't exit on job validation errors (warn only)",
26
+ )
27
+ def start(
28
+ once: bool,
29
+ workers: int | None,
30
+ dry_run: bool,
31
+ jobs_modules: tuple[str, ...],
32
+ config_path: str,
33
+ no_strict: bool,
34
+ ) -> None:
35
+ """Start the brawny daemon (job runner and transaction executor)."""
36
+ from brawny.config import Config
37
+ from brawny.daemon import BrawnyDaemon, RuntimeOverrides
38
+ from brawny.logging import setup_logging, set_runtime_logging, get_logger
39
+ from brawny.model.enums import LogFormat
40
+ from brawny.metrics import (
41
+ PrometheusMetricsProvider,
42
+ set_metrics_provider,
43
+ start_metrics_server,
44
+ )
45
+ from brawny.model.enums import IntentStatus
46
+ from brawny.scheduler.shutdown import ShutdownContext, ShutdownHandler
47
+
48
+ if not config_path or not os.path.exists(config_path):
49
+ click.echo(
50
+ f"Config file is required for run and was not found: {config_path}",
51
+ err=True,
52
+ )
53
+ sys.exit(1)
54
+
55
+ config = Config.from_yaml(config_path)
56
+ config, overrides_applied = config.apply_env_overrides()
57
+
58
+ log_level = os.environ.get("BRAWNY_LOG_LEVEL", "INFO")
59
+ # Start in startup mode (human-readable, warnings only)
60
+ setup_logging(log_level, LogFormat.JSON, config.chain_id, mode="startup")
61
+
62
+ click.echo(f"Config: {config_path}")
63
+ metrics_bind = f"127.0.0.1:{config.metrics_port}"
64
+ try:
65
+ provider = PrometheusMetricsProvider()
66
+ set_metrics_provider(provider)
67
+ start_metrics_server(metrics_bind, provider)
68
+ click.echo(f"Metrics: http://{metrics_bind}/metrics")
69
+ except Exception as e:
70
+ click.echo(click.style(f"Failed to start metrics server: {e}", fg="red"), err=True)
71
+ sys.exit(1)
72
+
73
+ # Build runtime overrides
74
+ runtime_overrides = RuntimeOverrides(
75
+ dry_run=dry_run,
76
+ once=once,
77
+ worker_count=workers,
78
+ strict_validation=not no_strict,
79
+ )
80
+
81
+ click.echo("Starting brawny runner...")
82
+ click.echo(f" Chain ID: {config.chain_id}")
83
+ if config.rpc_groups:
84
+ from brawny.config.routing import resolve_default_group
85
+
86
+ default_group = resolve_default_group(config)
87
+ click.echo(f" RPC Default Group: {default_group}")
88
+ click.echo(f" RPC Endpoints: {len(config.rpc_groups[default_group].endpoints)}")
89
+ click.echo(f" Workers: {workers or config.worker_count}")
90
+ if dry_run:
91
+ click.echo(" Mode: DRY RUN (no transactions)")
92
+ if once:
93
+ click.echo(" Mode: Single iteration")
94
+
95
+ # Initialize daemon
96
+ daemon = BrawnyDaemon(
97
+ config,
98
+ overrides=runtime_overrides,
99
+ extra_modules=list(jobs_modules),
100
+ )
101
+
102
+ validation_errors, routing_errors, startup_messages = daemon.initialize()
103
+
104
+ # Show signers
105
+ if daemon.keystore:
106
+ signers = daemon.keystore.list_keys()
107
+ if signers:
108
+ click.echo(f" Signers: {len(signers)} ({', '.join(signers)})")
109
+
110
+ # Show startup warnings/errors
111
+ for msg in startup_messages:
112
+ color = "yellow" if msg.level == "warning" else "red"
113
+ symbol = "\u26a0" if msg.level == "warning" else "\u2717"
114
+ text = f" {symbol} {msg.message}"
115
+ if msg.fix:
116
+ text += click.style(f" \u2192 {msg.fix}", dim=True)
117
+ click.echo(click.style(text, fg=color))
118
+
119
+ # Report validation errors
120
+ if validation_errors:
121
+ click.echo("")
122
+ click.echo(click.style("Job validation errors:", fg="red", bold=True))
123
+ for job_id, errors in validation_errors.items():
124
+ click.echo(f" {job_id}:")
125
+ for error in errors:
126
+ click.echo(f" - {error}")
127
+
128
+ click.echo("")
129
+ click.echo(click.style("Tip:", dim=True) + " Remove the @job decorator to hide incomplete jobs from discovery.")
130
+
131
+ if runtime_overrides.strict_validation:
132
+ click.echo("")
133
+ click.echo(
134
+ "Exiting due to validation errors. Use --no-strict to continue anyway.",
135
+ err=True,
136
+ )
137
+ sys.exit(1)
138
+ else:
139
+ click.echo("")
140
+ click.echo(
141
+ click.style("Continuing despite validation errors (--no-strict)", fg="yellow"),
142
+ )
143
+
144
+ # Report routing errors
145
+ if routing_errors:
146
+ click.echo("")
147
+ click.echo(click.style("Job routing configuration errors:", fg="red", bold=True))
148
+ for error in routing_errors:
149
+ click.echo(f" - {error}")
150
+
151
+ if runtime_overrides.strict_validation:
152
+ click.echo("")
153
+ click.echo(
154
+ "Exiting due to routing errors. Use --no-strict to continue anyway.",
155
+ err=True,
156
+ )
157
+ sys.exit(1)
158
+ else:
159
+ click.echo("")
160
+ click.echo(
161
+ click.style("Continuing despite routing errors (--no-strict)", fg="yellow"),
162
+ )
163
+
164
+ # Show discovered jobs
165
+ jobs = daemon.jobs
166
+ if not jobs:
167
+ click.echo(" Jobs discovered: 0")
168
+ else:
169
+ click.echo(f" Jobs discovered: {len(jobs)}")
170
+ job_items = list(jobs.items())[:20]
171
+ for job_id, job in job_items:
172
+ click.echo(f" - {job_id}: {job.name}")
173
+ if len(jobs) > 20:
174
+ click.echo(click.style(f" ... and {len(jobs) - 20} more", dim=True))
175
+
176
+ # Check for orphaned jobs
177
+ db = daemon.db
178
+ db_jobs = db.list_all_jobs()
179
+ discovered_job_ids = set(jobs.keys())
180
+ orphaned = [j for j in db_jobs if j.job_id not in discovered_job_ids and j.enabled]
181
+ if orphaned:
182
+ click.echo("")
183
+ click.echo(
184
+ click.style("Orphaned jobs", fg="yellow", bold=True)
185
+ + click.style(" (in database but not discovered):", fg="yellow")
186
+ )
187
+ for j in orphaned:
188
+ click.echo(click.style(f" - {j.job_id}", fg="yellow"))
189
+ click.echo(click.style(" These won't run. Check: --jobs-module or ./jobs/", dim=True))
190
+
191
+ # Warn if no jobs registered
192
+ if not jobs:
193
+ pending_intents = db.get_intents_by_status(
194
+ IntentStatus.PENDING.value, chain_id=config.chain_id
195
+ )
196
+ pending_count = len(pending_intents)
197
+
198
+ click.echo("")
199
+ if pending_count > 0:
200
+ click.echo(
201
+ click.style(f"No jobs registered but {pending_count} pending intent(s) found.", fg="yellow"),
202
+ )
203
+ click.echo(
204
+ click.style(" Continuing to monitor pending transactions.", dim=True),
205
+ )
206
+ else:
207
+ modules = list(jobs_modules)
208
+ if modules:
209
+ click.echo(
210
+ click.style("No jobs registered.", fg="yellow")
211
+ + " Ensure --jobs-module values are correct and jobs use @job.",
212
+ )
213
+ else:
214
+ click.echo(
215
+ click.style("No jobs registered.", fg="yellow")
216
+ + " Add jobs under ./jobs or use --jobs-module.",
217
+ )
218
+
219
+ click.echo("\n--- Starting brawny ---")
220
+
221
+ # Switch to runtime logging (full structured JSON)
222
+ set_runtime_logging()
223
+ log = get_logger(__name__)
224
+
225
+ # Create shutdown handler
226
+ nonce_manager = daemon._executor.nonce_manager if daemon._executor else None
227
+ shutdown_handler = ShutdownHandler(config, db, daemon.rpc, nonce_manager=nonce_manager)
228
+ shutdown_handler.register_callback(lambda: daemon.stop(timeout=5.0))
229
+
230
+ with ShutdownContext(shutdown_handler):
231
+ if once:
232
+ try:
233
+ daemon.run(blocking=True)
234
+ click.echo("Single iteration complete.")
235
+ except Exception as e:
236
+ click.echo(f"Error: {e}", err=True)
237
+ sys.exit(1)
238
+ else:
239
+ click.echo("Polling for new blocks... (Ctrl+C to stop)")
240
+ try:
241
+ daemon.run(blocking=True)
242
+ except KeyboardInterrupt:
243
+ click.echo("\nShutdown requested...")
244
+
245
+ click.echo("Shutdown complete.")
246
+
247
+
248
+ def register(main) -> None:
249
+ main.add_command(start)