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,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)