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.
- brawny/__init__.py +106 -0
- brawny/_context.py +232 -0
- brawny/_rpc/__init__.py +38 -0
- brawny/_rpc/broadcast.py +172 -0
- brawny/_rpc/clients.py +98 -0
- brawny/_rpc/context.py +49 -0
- brawny/_rpc/errors.py +252 -0
- brawny/_rpc/gas.py +158 -0
- brawny/_rpc/manager.py +982 -0
- brawny/_rpc/selector.py +156 -0
- brawny/accounts.py +534 -0
- brawny/alerts/__init__.py +132 -0
- brawny/alerts/abi_resolver.py +530 -0
- brawny/alerts/base.py +152 -0
- brawny/alerts/context.py +271 -0
- brawny/alerts/contracts.py +635 -0
- brawny/alerts/encoded_call.py +201 -0
- brawny/alerts/errors.py +267 -0
- brawny/alerts/events.py +680 -0
- brawny/alerts/function_caller.py +364 -0
- brawny/alerts/health.py +185 -0
- brawny/alerts/routing.py +118 -0
- brawny/alerts/send.py +364 -0
- brawny/api.py +660 -0
- brawny/chain.py +93 -0
- brawny/cli/__init__.py +16 -0
- brawny/cli/app.py +17 -0
- brawny/cli/bootstrap.py +37 -0
- brawny/cli/commands/__init__.py +41 -0
- brawny/cli/commands/abi.py +93 -0
- brawny/cli/commands/accounts.py +632 -0
- brawny/cli/commands/console.py +495 -0
- brawny/cli/commands/contract.py +139 -0
- brawny/cli/commands/health.py +112 -0
- brawny/cli/commands/init_project.py +86 -0
- brawny/cli/commands/intents.py +130 -0
- brawny/cli/commands/job_dev.py +254 -0
- brawny/cli/commands/jobs.py +308 -0
- brawny/cli/commands/logs.py +87 -0
- brawny/cli/commands/maintenance.py +182 -0
- brawny/cli/commands/migrate.py +51 -0
- brawny/cli/commands/networks.py +253 -0
- brawny/cli/commands/run.py +249 -0
- brawny/cli/commands/script.py +209 -0
- brawny/cli/commands/signer.py +248 -0
- brawny/cli/helpers.py +265 -0
- brawny/cli_templates.py +1445 -0
- brawny/config/__init__.py +74 -0
- brawny/config/models.py +404 -0
- brawny/config/parser.py +633 -0
- brawny/config/routing.py +55 -0
- brawny/config/validation.py +246 -0
- brawny/daemon/__init__.py +14 -0
- brawny/daemon/context.py +69 -0
- brawny/daemon/core.py +702 -0
- brawny/daemon/loops.py +327 -0
- brawny/db/__init__.py +78 -0
- brawny/db/base.py +986 -0
- brawny/db/base_new.py +165 -0
- brawny/db/circuit_breaker.py +97 -0
- brawny/db/global_cache.py +298 -0
- brawny/db/mappers.py +182 -0
- brawny/db/migrate.py +349 -0
- brawny/db/migrations/001_init.sql +186 -0
- brawny/db/migrations/002_add_included_block.sql +7 -0
- brawny/db/migrations/003_add_broadcast_at.sql +10 -0
- brawny/db/migrations/004_broadcast_binding.sql +20 -0
- brawny/db/migrations/005_add_retry_after.sql +9 -0
- brawny/db/migrations/006_add_retry_count_column.sql +11 -0
- brawny/db/migrations/007_add_gap_tracking.sql +18 -0
- brawny/db/migrations/008_add_transactions.sql +72 -0
- brawny/db/migrations/009_add_intent_metadata.sql +5 -0
- brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
- brawny/db/migrations/011_add_job_logs.sql +24 -0
- brawny/db/migrations/012_add_claimed_by.sql +5 -0
- brawny/db/ops/__init__.py +29 -0
- brawny/db/ops/attempts.py +108 -0
- brawny/db/ops/blocks.py +83 -0
- brawny/db/ops/cache.py +93 -0
- brawny/db/ops/intents.py +296 -0
- brawny/db/ops/jobs.py +110 -0
- brawny/db/ops/logs.py +97 -0
- brawny/db/ops/nonces.py +322 -0
- brawny/db/postgres.py +2535 -0
- brawny/db/postgres_new.py +196 -0
- brawny/db/queries.py +584 -0
- brawny/db/sqlite.py +2733 -0
- brawny/db/sqlite_new.py +191 -0
- brawny/history.py +126 -0
- brawny/interfaces.py +136 -0
- brawny/invariants.py +155 -0
- brawny/jobs/__init__.py +26 -0
- brawny/jobs/base.py +287 -0
- brawny/jobs/discovery.py +233 -0
- brawny/jobs/job_validation.py +111 -0
- brawny/jobs/kv.py +125 -0
- brawny/jobs/registry.py +283 -0
- brawny/keystore.py +484 -0
- brawny/lifecycle.py +551 -0
- brawny/logging.py +290 -0
- brawny/metrics.py +594 -0
- brawny/model/__init__.py +53 -0
- brawny/model/contexts.py +319 -0
- brawny/model/enums.py +70 -0
- brawny/model/errors.py +194 -0
- brawny/model/events.py +93 -0
- brawny/model/startup.py +20 -0
- brawny/model/types.py +483 -0
- brawny/networks/__init__.py +96 -0
- brawny/networks/config.py +269 -0
- brawny/networks/manager.py +423 -0
- brawny/obs/__init__.py +67 -0
- brawny/obs/emit.py +158 -0
- brawny/obs/health.py +175 -0
- brawny/obs/heartbeat.py +133 -0
- brawny/reconciliation.py +108 -0
- brawny/scheduler/__init__.py +19 -0
- brawny/scheduler/poller.py +472 -0
- brawny/scheduler/reorg.py +632 -0
- brawny/scheduler/runner.py +708 -0
- brawny/scheduler/shutdown.py +371 -0
- brawny/script_tx.py +297 -0
- brawny/scripting.py +251 -0
- brawny/startup.py +76 -0
- brawny/telegram.py +393 -0
- brawny/testing.py +108 -0
- brawny/tx/__init__.py +41 -0
- brawny/tx/executor.py +1071 -0
- brawny/tx/fees.py +50 -0
- brawny/tx/intent.py +423 -0
- brawny/tx/monitor.py +628 -0
- brawny/tx/nonce.py +498 -0
- brawny/tx/replacement.py +456 -0
- brawny/tx/utils.py +26 -0
- brawny/utils.py +205 -0
- brawny/validation.py +69 -0
- brawny-0.1.13.dist-info/METADATA +156 -0
- brawny-0.1.13.dist-info/RECORD +141 -0
- brawny-0.1.13.dist-info/WHEEL +5 -0
- brawny-0.1.13.dist-info/entry_points.txt +2 -0
- brawny-0.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Jobs management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def jobs() -> None:
|
|
10
|
+
"""Manage jobs."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@jobs.command("list")
|
|
15
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
16
|
+
def jobs_list(config_path: str | None) -> None:
|
|
17
|
+
"""List all registered jobs.
|
|
18
|
+
|
|
19
|
+
Shows jobs discovered from code, with status from database.
|
|
20
|
+
"""
|
|
21
|
+
# Suppress logging FIRST before any brawny imports
|
|
22
|
+
from brawny.cli.helpers import suppress_logging
|
|
23
|
+
|
|
24
|
+
suppress_logging()
|
|
25
|
+
|
|
26
|
+
from brawny.cli.helpers import discover_jobs_for_cli, get_config, get_db
|
|
27
|
+
from brawny.jobs.registry import get_registry
|
|
28
|
+
|
|
29
|
+
# Discover jobs from code (same logic as brawny start)
|
|
30
|
+
config = get_config(config_path)
|
|
31
|
+
discover_jobs_for_cli(config)
|
|
32
|
+
|
|
33
|
+
registry = get_registry()
|
|
34
|
+
code_jobs = {job.job_id: job for job in registry.get_all()}
|
|
35
|
+
|
|
36
|
+
# Get DB status for discovered jobs
|
|
37
|
+
db = get_db(config_path)
|
|
38
|
+
try:
|
|
39
|
+
db_jobs = {j.job_id: j for j in db.list_all_jobs()}
|
|
40
|
+
|
|
41
|
+
if not code_jobs:
|
|
42
|
+
click.echo(click.style("No jobs discovered.", dim=True))
|
|
43
|
+
click.echo(" Check: ./jobs/ directory or use --jobs-module.")
|
|
44
|
+
|
|
45
|
+
# Show orphaned jobs if any
|
|
46
|
+
if db_jobs:
|
|
47
|
+
click.echo()
|
|
48
|
+
click.echo(click.style("Jobs in database (not discovered):", fg="yellow"))
|
|
49
|
+
for job_id, job in sorted(db_jobs.items()):
|
|
50
|
+
status = "enabled" if job.enabled else "disabled"
|
|
51
|
+
click.echo(f" - {job_id} ({status})")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
click.echo()
|
|
55
|
+
for job_id in sorted(code_jobs.keys()):
|
|
56
|
+
job = code_jobs[job_id]
|
|
57
|
+
db_job = db_jobs.get(job_id)
|
|
58
|
+
|
|
59
|
+
# Get interval from code (authoritative)
|
|
60
|
+
interval = str(job.check_interval_blocks)
|
|
61
|
+
|
|
62
|
+
# Get enabled status from DB, default to True for new jobs
|
|
63
|
+
enabled = db_job.enabled if db_job else True
|
|
64
|
+
|
|
65
|
+
# Status indicator
|
|
66
|
+
if enabled:
|
|
67
|
+
status = click.style("✓ enabled ", fg="green")
|
|
68
|
+
else:
|
|
69
|
+
status = click.style("✗ disabled", fg="red")
|
|
70
|
+
|
|
71
|
+
click.echo(f" {status} {job_id} {click.style(f'every {interval} blocks', dim=True)}")
|
|
72
|
+
|
|
73
|
+
click.echo()
|
|
74
|
+
|
|
75
|
+
# Warn about orphaned jobs (in DB but not discovered)
|
|
76
|
+
orphaned = set(db_jobs.keys()) - set(code_jobs.keys())
|
|
77
|
+
if orphaned:
|
|
78
|
+
click.echo(click.style(f"Warning: {len(orphaned)} job(s) in database but not discovered:", fg="yellow"))
|
|
79
|
+
for job_id in sorted(orphaned):
|
|
80
|
+
job = db_jobs[job_id]
|
|
81
|
+
status = "enabled" if job.enabled else "disabled"
|
|
82
|
+
click.echo(f" - {job_id} ({status})")
|
|
83
|
+
click.echo()
|
|
84
|
+
click.echo(" To remove orphaned jobs from database:")
|
|
85
|
+
click.echo(click.style(" brawny jobs remove <job_id>", fg="cyan"))
|
|
86
|
+
click.echo()
|
|
87
|
+
|
|
88
|
+
finally:
|
|
89
|
+
db.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@jobs.command("validate")
|
|
93
|
+
@click.option(
|
|
94
|
+
"--jobs-module",
|
|
95
|
+
"jobs_modules",
|
|
96
|
+
multiple=True,
|
|
97
|
+
help="Additional job module(s) to load",
|
|
98
|
+
)
|
|
99
|
+
@click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
|
|
100
|
+
def jobs_validate(jobs_modules: tuple[str, ...], config_path: str) -> None:
|
|
101
|
+
"""Validate job definitions including signer configuration."""
|
|
102
|
+
import os
|
|
103
|
+
import sys
|
|
104
|
+
|
|
105
|
+
from brawny.config import Config
|
|
106
|
+
from brawny.jobs.registry import get_registry
|
|
107
|
+
from brawny.jobs.discovery import auto_discover_jobs, discover_jobs
|
|
108
|
+
from brawny.jobs.job_validation import validate_all_jobs
|
|
109
|
+
from brawny.keystore import create_keystore
|
|
110
|
+
|
|
111
|
+
if not config_path or not os.path.exists(config_path):
|
|
112
|
+
click.echo(f"Config file not found: {config_path}", err=True)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
config = Config.from_yaml(config_path)
|
|
116
|
+
config, _ = config.apply_env_overrides()
|
|
117
|
+
registry = get_registry()
|
|
118
|
+
registry.clear()
|
|
119
|
+
|
|
120
|
+
modules = list(jobs_modules)
|
|
121
|
+
if modules:
|
|
122
|
+
discover_jobs(modules)
|
|
123
|
+
else:
|
|
124
|
+
auto_discover_jobs()
|
|
125
|
+
all_jobs = registry.get_all()
|
|
126
|
+
|
|
127
|
+
if not all_jobs:
|
|
128
|
+
click.echo("No jobs discovered.", err=True)
|
|
129
|
+
click.echo(" Add jobs under ./jobs or use --jobs-module", err=True)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
# Try to load keystore for signer validation
|
|
133
|
+
keystore = None
|
|
134
|
+
try:
|
|
135
|
+
keystore = create_keystore(
|
|
136
|
+
config.keystore_type,
|
|
137
|
+
keystore_path=config.keystore_path,
|
|
138
|
+
allowed_signers=[],
|
|
139
|
+
)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
click.echo(click.style(f"Warning: Could not load keystore ({e})", fg="yellow"))
|
|
142
|
+
click.echo(" Signer validation will be skipped.")
|
|
143
|
+
click.echo()
|
|
144
|
+
|
|
145
|
+
click.echo(f"Validating {len(all_jobs)} job(s)...")
|
|
146
|
+
click.echo("-" * 50)
|
|
147
|
+
|
|
148
|
+
errors = validate_all_jobs({job.job_id: job for job in all_jobs}, keystore=keystore)
|
|
149
|
+
|
|
150
|
+
passed = 0
|
|
151
|
+
failed = 0
|
|
152
|
+
for job in all_jobs:
|
|
153
|
+
job_id = job.job_id
|
|
154
|
+
if job_id in errors:
|
|
155
|
+
click.echo(click.style(f" ✗ {job_id}", fg="red"))
|
|
156
|
+
for error in errors[job_id]:
|
|
157
|
+
click.echo(f" - {error}")
|
|
158
|
+
failed += 1
|
|
159
|
+
else:
|
|
160
|
+
click.echo(click.style(f" ✓ {job_id}", fg="green"))
|
|
161
|
+
passed += 1
|
|
162
|
+
|
|
163
|
+
click.echo("-" * 50)
|
|
164
|
+
if failed > 0:
|
|
165
|
+
click.echo(click.style(f"{passed} passed, {failed} failed", fg="red"))
|
|
166
|
+
click.echo()
|
|
167
|
+
click.echo(click.style("Tip:", dim=True) + " Remove the @job decorator to hide incomplete jobs from discovery.")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
else:
|
|
170
|
+
click.echo(click.style(f"{passed} passed", fg="green"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@jobs.command("enable")
|
|
174
|
+
@click.argument("job_id")
|
|
175
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
176
|
+
def jobs_enable(job_id: str, config_path: str | None) -> None:
|
|
177
|
+
"""Enable a job."""
|
|
178
|
+
from brawny.cli.helpers import get_db
|
|
179
|
+
|
|
180
|
+
db = get_db(config_path)
|
|
181
|
+
try:
|
|
182
|
+
updated = db.set_job_enabled(job_id, True)
|
|
183
|
+
if updated:
|
|
184
|
+
click.echo(f"Job '{job_id}' enabled.")
|
|
185
|
+
else:
|
|
186
|
+
click.echo(f"Job '{job_id}' not found.", err=True)
|
|
187
|
+
finally:
|
|
188
|
+
db.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@jobs.command("disable")
|
|
192
|
+
@click.argument("job_id")
|
|
193
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
194
|
+
def jobs_disable(job_id: str, config_path: str | None) -> None:
|
|
195
|
+
"""Disable a job."""
|
|
196
|
+
from brawny.cli.helpers import get_db
|
|
197
|
+
|
|
198
|
+
db = get_db(config_path)
|
|
199
|
+
try:
|
|
200
|
+
updated = db.set_job_enabled(job_id, False)
|
|
201
|
+
if updated:
|
|
202
|
+
click.echo(f"Job '{job_id}' disabled.")
|
|
203
|
+
else:
|
|
204
|
+
click.echo(f"Job '{job_id}' not found.", err=True)
|
|
205
|
+
finally:
|
|
206
|
+
db.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@jobs.command("remove")
|
|
210
|
+
@click.argument("job_id")
|
|
211
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
212
|
+
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
|
|
213
|
+
def jobs_remove(job_id: str, config_path: str | None, force: bool) -> None:
|
|
214
|
+
"""Remove a job from the database.
|
|
215
|
+
|
|
216
|
+
Use this to clean up orphaned jobs (jobs in database but not discovered from code).
|
|
217
|
+
"""
|
|
218
|
+
from brawny.cli.helpers import get_db
|
|
219
|
+
|
|
220
|
+
db = get_db(config_path)
|
|
221
|
+
try:
|
|
222
|
+
# Check if job exists
|
|
223
|
+
job = db.get_job(job_id)
|
|
224
|
+
if not job:
|
|
225
|
+
click.echo(f"Job '{job_id}' not found in database.", err=True)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# Confirm unless --force
|
|
229
|
+
if not force:
|
|
230
|
+
click.echo(f"This will remove job '{job_id}' and its key-value data from the database.")
|
|
231
|
+
if not click.confirm("Continue?"):
|
|
232
|
+
click.echo("Cancelled.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
deleted = db.delete_job(job_id)
|
|
236
|
+
if deleted:
|
|
237
|
+
click.echo(f"Job '{job_id}' removed.")
|
|
238
|
+
else:
|
|
239
|
+
click.echo(f"Failed to remove job '{job_id}'.", err=True)
|
|
240
|
+
finally:
|
|
241
|
+
db.close()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@jobs.command("status")
|
|
245
|
+
@click.argument("job_id")
|
|
246
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
247
|
+
def jobs_status(job_id: str, config_path: str | None) -> None:
|
|
248
|
+
"""Show status for a job."""
|
|
249
|
+
from brawny.cli.helpers import get_db
|
|
250
|
+
|
|
251
|
+
db = get_db(config_path)
|
|
252
|
+
try:
|
|
253
|
+
job = db.get_job(job_id)
|
|
254
|
+
if not job:
|
|
255
|
+
click.echo(f"Job '{job_id}' not found.", err=True)
|
|
256
|
+
return
|
|
257
|
+
click.echo(f"\nJob: {job.job_id}")
|
|
258
|
+
click.echo("-" * 40)
|
|
259
|
+
click.echo(f" Name: {job.job_name}")
|
|
260
|
+
click.echo(f" Enabled: {job.enabled}")
|
|
261
|
+
click.echo(f" Check Interval: {job.check_interval_blocks} blocks")
|
|
262
|
+
click.echo(f" Last Checked Block: {job.last_checked_block_number or 'Never'}")
|
|
263
|
+
click.echo(f" Last Triggered Block: {job.last_triggered_block_number or 'Never'}")
|
|
264
|
+
click.echo(f" Created: {job.created_at}")
|
|
265
|
+
click.echo(f" Updated: {job.updated_at}")
|
|
266
|
+
click.echo()
|
|
267
|
+
finally:
|
|
268
|
+
db.close()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@jobs.command("run")
|
|
272
|
+
@click.argument("job_id")
|
|
273
|
+
@click.option("--at-block", type=int, help="Run check/build against this block")
|
|
274
|
+
@click.option("--dry-run", is_flag=True, help="Run check only (skip build_intent)")
|
|
275
|
+
@click.option(
|
|
276
|
+
"--jobs-module",
|
|
277
|
+
"jobs_modules",
|
|
278
|
+
multiple=True,
|
|
279
|
+
help="Additional job module(s) to load",
|
|
280
|
+
)
|
|
281
|
+
@click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
|
|
282
|
+
def jobs_run(
|
|
283
|
+
job_id: str,
|
|
284
|
+
at_block: int | None,
|
|
285
|
+
dry_run: bool,
|
|
286
|
+
jobs_modules: tuple[str, ...],
|
|
287
|
+
config_path: str,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Run check/build for a single job without sending transactions.
|
|
290
|
+
|
|
291
|
+
Developer utility for testing jobs locally.
|
|
292
|
+
"""
|
|
293
|
+
# Import the implementation from job_dev
|
|
294
|
+
from brawny.cli.commands.job_dev import job_run as _job_run_impl
|
|
295
|
+
# Use Click's context to invoke the command
|
|
296
|
+
ctx = click.get_current_context()
|
|
297
|
+
ctx.invoke(
|
|
298
|
+
_job_run_impl,
|
|
299
|
+
job_id=job_id,
|
|
300
|
+
at_block=at_block,
|
|
301
|
+
dry_run=dry_run,
|
|
302
|
+
jobs_modules=jobs_modules,
|
|
303
|
+
config_path=config_path,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def register(main) -> None:
|
|
308
|
+
main.add_command(jobs)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Job logs commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def logs() -> None:
|
|
13
|
+
"""View and manage job logs."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@logs.command("list")
|
|
18
|
+
@click.option("--job", "job_id", help="Filter by job ID")
|
|
19
|
+
@click.option("--latest", is_flag=True, help="Show only latest per job")
|
|
20
|
+
@click.option("--limit", default=20, help="Max entries to show")
|
|
21
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
22
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
23
|
+
def list_logs(
|
|
24
|
+
job_id: str | None,
|
|
25
|
+
latest: bool,
|
|
26
|
+
limit: int,
|
|
27
|
+
as_json: bool,
|
|
28
|
+
config_path: str | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""List job logs."""
|
|
31
|
+
from brawny.cli.helpers import suppress_logging
|
|
32
|
+
|
|
33
|
+
suppress_logging()
|
|
34
|
+
|
|
35
|
+
from brawny.cli.helpers import get_config, get_db
|
|
36
|
+
from brawny.db.ops import logs as log_ops
|
|
37
|
+
|
|
38
|
+
config = get_config(config_path)
|
|
39
|
+
db = get_db(config_path)
|
|
40
|
+
|
|
41
|
+
if latest:
|
|
42
|
+
entries = log_ops.list_latest_logs(db, config.chain_id)
|
|
43
|
+
elif job_id:
|
|
44
|
+
entries = log_ops.list_logs(db, config.chain_id, job_id, limit)
|
|
45
|
+
else:
|
|
46
|
+
entries = log_ops.list_all_logs(db, config.chain_id, limit)
|
|
47
|
+
|
|
48
|
+
if not entries:
|
|
49
|
+
click.echo("No logs found.")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if as_json:
|
|
53
|
+
click.echo(json.dumps(entries, default=str, indent=2))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
for entry in entries:
|
|
57
|
+
ts = entry["ts"]
|
|
58
|
+
if isinstance(ts, datetime):
|
|
59
|
+
ts = ts.strftime("%Y-%m-%d %H:%M:%S")
|
|
60
|
+
level = entry["level"]
|
|
61
|
+
level_color = "yellow" if level == "warn" else ("red" if level == "error" else None)
|
|
62
|
+
level_str = click.style(f"({level})", fg=level_color) if level_color else f"({level})"
|
|
63
|
+
click.echo(f"[{ts}] {entry['job_id']} {level_str}: {entry['fields']}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@logs.command("cleanup")
|
|
67
|
+
@click.option("--older-than", default=7, type=int, help="Delete logs older than N days")
|
|
68
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
69
|
+
def cleanup_logs(older_than: int, config_path: str | None) -> None:
|
|
70
|
+
"""Delete old job logs."""
|
|
71
|
+
from brawny.cli.helpers import suppress_logging
|
|
72
|
+
|
|
73
|
+
suppress_logging()
|
|
74
|
+
|
|
75
|
+
from brawny.cli.helpers import get_config, get_db
|
|
76
|
+
from brawny.db.ops import logs as log_ops
|
|
77
|
+
|
|
78
|
+
config = get_config(config_path)
|
|
79
|
+
db = get_db(config_path)
|
|
80
|
+
cutoff = datetime.utcnow() - timedelta(days=older_than)
|
|
81
|
+
deleted = log_ops.delete_old_logs(db, config.chain_id, cutoff)
|
|
82
|
+
click.echo(f"Deleted {deleted} logs older than {older_than} days.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def register(main: click.Group) -> None:
|
|
86
|
+
"""Register logs commands."""
|
|
87
|
+
main.add_command(logs)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Maintenance commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from brawny.cli.helpers import get_config, get_db
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
def reconcile() -> None:
|
|
12
|
+
"""Run nonce reconciliation."""
|
|
13
|
+
from brawny.config import get_config
|
|
14
|
+
from brawny.db import create_database
|
|
15
|
+
from brawny._rpc import RPCManager
|
|
16
|
+
from brawny.tx.nonce import NonceManager
|
|
17
|
+
|
|
18
|
+
click.echo("Running nonce reconciliation...")
|
|
19
|
+
|
|
20
|
+
config = get_config()
|
|
21
|
+
db = create_database(
|
|
22
|
+
config.database_url,
|
|
23
|
+
pool_size=config.database_pool_size,
|
|
24
|
+
pool_max_overflow=config.database_pool_max_overflow,
|
|
25
|
+
pool_timeout=config.database_pool_timeout_seconds,
|
|
26
|
+
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
27
|
+
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
28
|
+
)
|
|
29
|
+
db.connect()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
rpc = RPCManager.from_config(config)
|
|
33
|
+
nonce_manager = NonceManager(db, rpc, config.chain_id)
|
|
34
|
+
nonce_manager.reconcile()
|
|
35
|
+
click.echo("Nonce reconciliation complete.")
|
|
36
|
+
|
|
37
|
+
finally:
|
|
38
|
+
db.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.command()
|
|
42
|
+
@click.option("--older-than", default="30d", help="Delete intents older than (e.g., 30d)")
|
|
43
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
44
|
+
def cleanup(older_than: str, config_path: str | None) -> None:
|
|
45
|
+
"""Clean up old data."""
|
|
46
|
+
if older_than.endswith("d"):
|
|
47
|
+
days = int(older_than[:-1])
|
|
48
|
+
else:
|
|
49
|
+
days = int(older_than)
|
|
50
|
+
|
|
51
|
+
db = get_db(config_path)
|
|
52
|
+
try:
|
|
53
|
+
deleted = db.cleanup_old_intents(days)
|
|
54
|
+
click.echo(f"Deleted {deleted} old intents.")
|
|
55
|
+
finally:
|
|
56
|
+
db.close()
|
|
57
|
+
|
|
58
|
+
@click.command("audit-intents")
|
|
59
|
+
@click.option("--max-age-seconds", type=int, default=None, help="Max age for sending intents")
|
|
60
|
+
@click.option("--limit", default=100, help="Limit results")
|
|
61
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
62
|
+
def audit_intents(max_age_seconds: int | None, limit: int, config_path: str | None) -> None:
|
|
63
|
+
"""Audit intent state invariants and report inconsistencies."""
|
|
64
|
+
from brawny.config import Config
|
|
65
|
+
from brawny.metrics import INTENT_STATE_INCONSISTENT, get_metrics
|
|
66
|
+
|
|
67
|
+
if config_path:
|
|
68
|
+
config = Config.from_yaml(config_path)
|
|
69
|
+
config, _ = config.apply_env_overrides()
|
|
70
|
+
else:
|
|
71
|
+
from brawny.config import get_config
|
|
72
|
+
|
|
73
|
+
config = get_config()
|
|
74
|
+
|
|
75
|
+
db = get_db(config_path)
|
|
76
|
+
try:
|
|
77
|
+
age_seconds = max_age_seconds or config.claim_timeout_seconds
|
|
78
|
+
issues = db.list_intent_inconsistencies(
|
|
79
|
+
max_age_seconds=age_seconds,
|
|
80
|
+
limit=limit,
|
|
81
|
+
chain_id=config.chain_id,
|
|
82
|
+
)
|
|
83
|
+
if not issues:
|
|
84
|
+
click.echo("No intent inconsistencies found.")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
click.echo("\nIntent inconsistencies:")
|
|
88
|
+
click.echo("-" * 90)
|
|
89
|
+
click.echo(f"{'Intent ID':<38} {'Status':<12} {'Reason':<30}")
|
|
90
|
+
click.echo("-" * 90)
|
|
91
|
+
counts: dict[str, int] = {}
|
|
92
|
+
for issue in issues:
|
|
93
|
+
intent_id = str(issue["intent_id"])[:36]
|
|
94
|
+
status = issue.get("status", "")
|
|
95
|
+
reason = issue.get("reason", "")
|
|
96
|
+
counts[reason] = counts.get(reason, 0) + 1
|
|
97
|
+
click.echo(f"{intent_id:<38} {status:<12} {reason:<30}")
|
|
98
|
+
|
|
99
|
+
metrics = get_metrics()
|
|
100
|
+
for reason, count in counts.items():
|
|
101
|
+
metrics.counter(INTENT_STATE_INCONSISTENT).inc(
|
|
102
|
+
count,
|
|
103
|
+
chain_id=config.chain_id,
|
|
104
|
+
reason=reason,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
click.echo(f"\n(Showing {len(issues)} of {limit} max)")
|
|
108
|
+
finally:
|
|
109
|
+
db.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@click.command("repair-claims")
|
|
113
|
+
@click.option("--older-than", type=int, default=10, help="Minutes threshold")
|
|
114
|
+
@click.option("--execute", is_flag=True, help="Actually perform repair (dry-run by default)")
|
|
115
|
+
@click.option("--limit", type=int, default=100, help="Max intents to repair")
|
|
116
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
117
|
+
def repair_claims(
|
|
118
|
+
older_than: int,
|
|
119
|
+
execute: bool,
|
|
120
|
+
limit: int,
|
|
121
|
+
config_path: str | None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Release stuck CLAIMED intents with zero attempts."""
|
|
124
|
+
config = get_config(config_path)
|
|
125
|
+
db = get_db(config_path)
|
|
126
|
+
try:
|
|
127
|
+
if db.dialect == "sqlite":
|
|
128
|
+
query = """
|
|
129
|
+
SELECT i.intent_id, i.job_id, i.claimed_at
|
|
130
|
+
FROM tx_intents i
|
|
131
|
+
WHERE i.chain_id = ?
|
|
132
|
+
AND i.status = 'claimed'
|
|
133
|
+
AND (i.claimed_at IS NULL OR datetime(i.claimed_at) < datetime('now', ? || ' minutes'))
|
|
134
|
+
AND NOT EXISTS (SELECT 1 FROM tx_attempts a WHERE a.intent_id = i.intent_id)
|
|
135
|
+
ORDER BY (i.claimed_at IS NOT NULL), i.claimed_at ASC
|
|
136
|
+
LIMIT ?
|
|
137
|
+
"""
|
|
138
|
+
stuck = db.execute_returning(query, (config.chain_id, -older_than, limit))
|
|
139
|
+
else:
|
|
140
|
+
query = """
|
|
141
|
+
SELECT i.intent_id, i.job_id, i.claimed_at
|
|
142
|
+
FROM tx_intents i
|
|
143
|
+
WHERE i.chain_id = %s
|
|
144
|
+
AND i.status = 'claimed'
|
|
145
|
+
AND (i.claimed_at IS NULL OR i.claimed_at < NOW() - make_interval(mins => %s))
|
|
146
|
+
AND NOT EXISTS (SELECT 1 FROM tx_attempts a WHERE a.intent_id = i.intent_id)
|
|
147
|
+
ORDER BY i.claimed_at ASC NULLS FIRST
|
|
148
|
+
LIMIT %s
|
|
149
|
+
"""
|
|
150
|
+
stuck = db.execute_returning(query, (config.chain_id, older_than, limit))
|
|
151
|
+
|
|
152
|
+
if not stuck:
|
|
153
|
+
click.echo("No stuck claims found matching criteria.")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
click.echo(f"Found {len(stuck)} stuck claims (no attempts):")
|
|
157
|
+
for row in stuck[:10]:
|
|
158
|
+
click.echo(
|
|
159
|
+
f" - {row['intent_id']} (job={row['job_id']}, claimed={row['claimed_at']})"
|
|
160
|
+
)
|
|
161
|
+
if len(stuck) > 10:
|
|
162
|
+
click.echo(f" ... and {len(stuck) - 10} more")
|
|
163
|
+
|
|
164
|
+
if not execute:
|
|
165
|
+
click.echo("\nDry-run mode. Use --execute to repair.")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
repaired = 0
|
|
169
|
+
for row in stuck:
|
|
170
|
+
if db.release_intent_claim(row["intent_id"]):
|
|
171
|
+
repaired += 1
|
|
172
|
+
|
|
173
|
+
click.echo(f"\nRepaired {repaired}/{len(stuck)} intents.")
|
|
174
|
+
finally:
|
|
175
|
+
db.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def register(main) -> None:
|
|
179
|
+
main.add_command(reconcile)
|
|
180
|
+
main.add_command(cleanup)
|
|
181
|
+
main.add_command(audit_intents)
|
|
182
|
+
main.add_command(repair_claims)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Database migration commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from brawny.cli.helpers import get_db
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.option("--status", is_flag=True, help="Show migration status only")
|
|
12
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
13
|
+
def migrate(status: bool, config_path: str | None) -> None:
|
|
14
|
+
"""Run database migrations."""
|
|
15
|
+
from brawny.db.migrate import Migrator, verify_critical_schema
|
|
16
|
+
|
|
17
|
+
db = get_db(config_path)
|
|
18
|
+
try:
|
|
19
|
+
migrator = Migrator(db)
|
|
20
|
+
|
|
21
|
+
if status:
|
|
22
|
+
migrations = migrator.status()
|
|
23
|
+
if not migrations:
|
|
24
|
+
click.echo("No migrations found.")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
click.echo("\nMigration Status:")
|
|
28
|
+
click.echo("-" * 60)
|
|
29
|
+
for m in migrations:
|
|
30
|
+
status_icon = "[x]" if m["applied"] else "[ ]"
|
|
31
|
+
applied = f" ({m['applied_at']})" if m["applied_at"] else ""
|
|
32
|
+
click.echo(f" {status_icon} {m['version']} - {m['filename']}{applied}")
|
|
33
|
+
click.echo()
|
|
34
|
+
else:
|
|
35
|
+
pending = migrator.pending()
|
|
36
|
+
if not pending:
|
|
37
|
+
verify_critical_schema(db)
|
|
38
|
+
click.echo("No pending migrations.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
click.echo(f"Running {len(pending)} migration(s)...")
|
|
42
|
+
applied = migrator.migrate()
|
|
43
|
+
for m in applied:
|
|
44
|
+
click.echo(f" Applied: {m.version} - {m.filename}")
|
|
45
|
+
click.echo(f"\nSuccessfully applied {len(applied)} migration(s).")
|
|
46
|
+
finally:
|
|
47
|
+
db.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register(main) -> None:
|
|
51
|
+
main.add_command(migrate)
|