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,112 @@
|
|
|
1
|
+
"""Health check command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from brawny.cli.helpers import get_db, print_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option("--format", "fmt", default="json", help="Output format (json or text)")
|
|
16
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
17
|
+
def health(fmt: str, config_path: str | None) -> None:
|
|
18
|
+
"""Health check endpoint."""
|
|
19
|
+
from brawny.config import Config, get_config
|
|
20
|
+
from brawny._rpc import RPCManager
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
if config_path:
|
|
24
|
+
config = Config.from_yaml(config_path)
|
|
25
|
+
config, _ = config.apply_env_overrides()
|
|
26
|
+
else:
|
|
27
|
+
config = get_config()
|
|
28
|
+
except Exception as e:
|
|
29
|
+
if fmt == "json":
|
|
30
|
+
print_json({"status": "unhealthy", "error": f"Config error: {e}"})
|
|
31
|
+
else:
|
|
32
|
+
click.echo(f"UNHEALTHY: Config error: {e}")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
db = get_db(config_path)
|
|
36
|
+
result: dict[str, Any] = {
|
|
37
|
+
"status": "healthy",
|
|
38
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
39
|
+
"components": {},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
db_stats = db.get_database_stats()
|
|
44
|
+
result["components"]["database"] = {
|
|
45
|
+
"status": "ok",
|
|
46
|
+
"type": db_stats.get("type", "unknown"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
rpc = RPCManager.from_config(config)
|
|
51
|
+
rpc_health = rpc.get_health()
|
|
52
|
+
result["components"]["rpc"] = {
|
|
53
|
+
"status": "ok" if rpc_health["healthy_endpoints"] > 0 else "degraded",
|
|
54
|
+
"healthy_endpoints": rpc_health["healthy_endpoints"],
|
|
55
|
+
"total_endpoints": rpc_health["total_endpoints"],
|
|
56
|
+
"circuit_breaker_open": rpc_health["circuit_breaker_open"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
block_number = rpc.get_block_number()
|
|
61
|
+
chain_id = rpc.get_chain_id()
|
|
62
|
+
result["components"]["chain"] = {
|
|
63
|
+
"chain_id": chain_id,
|
|
64
|
+
"head_block": block_number,
|
|
65
|
+
}
|
|
66
|
+
except Exception as e:
|
|
67
|
+
result["components"]["chain"] = {
|
|
68
|
+
"status": "error",
|
|
69
|
+
"error": str(e)[:100],
|
|
70
|
+
}
|
|
71
|
+
result["status"] = "degraded"
|
|
72
|
+
except Exception as e:
|
|
73
|
+
result["components"]["rpc"] = {
|
|
74
|
+
"status": "error",
|
|
75
|
+
"error": str(e)[:100],
|
|
76
|
+
}
|
|
77
|
+
result["status"] = "degraded"
|
|
78
|
+
|
|
79
|
+
block_states = db_stats.get("block_states", [])
|
|
80
|
+
if block_states and "chain" in result["components"]:
|
|
81
|
+
result["components"]["chain"]["last_processed_block"] = block_states[0].get(
|
|
82
|
+
"last_block"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
intents_by_status = db_stats.get("intents_by_status", {})
|
|
86
|
+
result["intents"] = {
|
|
87
|
+
"pending": intents_by_status.get("pending", 0),
|
|
88
|
+
"claimed": intents_by_status.get("claimed", 0),
|
|
89
|
+
"created": intents_by_status.get("created", 0),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
except Exception as e:
|
|
93
|
+
result["status"] = "unhealthy"
|
|
94
|
+
result["error"] = str(e)[:200]
|
|
95
|
+
finally:
|
|
96
|
+
db.close()
|
|
97
|
+
|
|
98
|
+
if fmt == "json":
|
|
99
|
+
print_json(result)
|
|
100
|
+
else:
|
|
101
|
+
click.echo(f"Status: {result['status'].upper()}")
|
|
102
|
+
click.echo(f"Timestamp: {result['timestamp']}")
|
|
103
|
+
for name, component in result.get("components", {}).items():
|
|
104
|
+
status = component.get("status", "unknown")
|
|
105
|
+
click.echo(f" {name}: {status}")
|
|
106
|
+
|
|
107
|
+
if result["status"] != "healthy":
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def register(main) -> None:
|
|
112
|
+
main.add_command(health)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Project initialization command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from brawny.cli.helpers import (
|
|
10
|
+
_check_directory_empty,
|
|
11
|
+
_write_agents,
|
|
12
|
+
_print_success,
|
|
13
|
+
_validate_project_name,
|
|
14
|
+
_write_config,
|
|
15
|
+
_write_env_example,
|
|
16
|
+
_write_examples,
|
|
17
|
+
_write_gitignore,
|
|
18
|
+
_write_monitoring,
|
|
19
|
+
_write_pyproject,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.command()
|
|
24
|
+
@click.argument("project_name", required=False, default=None)
|
|
25
|
+
@click.option("--force", "-f", is_flag=True, help="Initialize even if directory is not empty")
|
|
26
|
+
def init(project_name: str | None, force: bool) -> None:
|
|
27
|
+
"""Initialize a new brawny project in the current directory.
|
|
28
|
+
|
|
29
|
+
PROJECT_NAME defaults to the current directory name if not provided.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
|
|
33
|
+
brawny init # Use current directory name
|
|
34
|
+
|
|
35
|
+
brawny init my_keeper # Use 'my_keeper' as project name
|
|
36
|
+
|
|
37
|
+
brawny init -f # Force init in non-empty directory
|
|
38
|
+
"""
|
|
39
|
+
project_dir = Path.cwd()
|
|
40
|
+
|
|
41
|
+
if project_name is None:
|
|
42
|
+
project_name = project_dir.name
|
|
43
|
+
|
|
44
|
+
_validate_project_name(project_name) # Validates name is usable
|
|
45
|
+
|
|
46
|
+
if not force:
|
|
47
|
+
_check_directory_empty(project_dir)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
# Normalize project name to valid Python package name
|
|
51
|
+
package_name = project_name.lower().replace("-", "_").replace(" ", "_")
|
|
52
|
+
|
|
53
|
+
# Create package_name, jobs, interfaces, and data structure
|
|
54
|
+
package_dir = project_dir / package_name
|
|
55
|
+
jobs_dir = project_dir / "jobs"
|
|
56
|
+
interfaces_dir = project_dir / "interfaces"
|
|
57
|
+
data_dir = project_dir / "data"
|
|
58
|
+
package_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
jobs_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
interfaces_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# Create __init__.py files
|
|
64
|
+
(package_dir / "__init__.py").touch()
|
|
65
|
+
(jobs_dir / "__init__.py").write_text(
|
|
66
|
+
'"""Job definitions - auto-discovered from ./jobs."""\n'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
_write_pyproject(project_dir, project_name, package_name)
|
|
70
|
+
_write_config(project_dir, package_name)
|
|
71
|
+
_write_env_example(project_dir)
|
|
72
|
+
_write_agents(project_dir)
|
|
73
|
+
_write_gitignore(project_dir)
|
|
74
|
+
_write_examples(jobs_dir / "_examples.py")
|
|
75
|
+
_write_monitoring(project_dir)
|
|
76
|
+
|
|
77
|
+
except click.ClickException:
|
|
78
|
+
raise
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise click.ClickException(str(exc)) from exc
|
|
81
|
+
|
|
82
|
+
_print_success(project_name, package_name)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def register(main) -> None:
|
|
86
|
+
main.add_command(init)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Transaction intent commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from brawny.cli.helpers import get_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
def intents() -> None:
|
|
14
|
+
"""Manage transaction intents."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@intents.command("list")
|
|
19
|
+
@click.option("--status", help="Filter by status")
|
|
20
|
+
@click.option("--job", help="Filter by job ID")
|
|
21
|
+
@click.option("--limit", default=50, help="Limit results")
|
|
22
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
23
|
+
def intents_list(status: str | None, job: str | None, limit: int, config_path: str | None) -> None:
|
|
24
|
+
"""List transaction intents."""
|
|
25
|
+
db = get_db(config_path)
|
|
26
|
+
try:
|
|
27
|
+
intents_data = db.list_intents_filtered(status=status, job_id=job, limit=limit)
|
|
28
|
+
if not intents_data:
|
|
29
|
+
click.echo("No intents found.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
click.echo("\nTransaction Intents:")
|
|
33
|
+
click.echo("-" * 120)
|
|
34
|
+
click.echo(f"{'Intent ID':<38} {'Job':<20} {'Status':<12} {'Created':<20} {'Retry After':<20}")
|
|
35
|
+
click.echo("-" * 120)
|
|
36
|
+
for intent in intents_data:
|
|
37
|
+
intent_id = str(intent["intent_id"])[:36]
|
|
38
|
+
job_id = intent["job_id"][:18]
|
|
39
|
+
created = str(intent["created_at"])[:19]
|
|
40
|
+
retry_after = str(intent.get("retry_after") or "")[:19]
|
|
41
|
+
click.echo(f"{intent_id:<38} {job_id:<20} {intent['status']:<12} {created:<20} {retry_after:<20}")
|
|
42
|
+
click.echo(f"\n(Showing {len(intents_data)} of {limit} max)")
|
|
43
|
+
finally:
|
|
44
|
+
db.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@intents.command("show")
|
|
48
|
+
@click.argument("intent_id")
|
|
49
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
50
|
+
def intents_show(intent_id: str, config_path: str | None) -> None:
|
|
51
|
+
"""Show intent details."""
|
|
52
|
+
from uuid import UUID
|
|
53
|
+
|
|
54
|
+
db = get_db(config_path)
|
|
55
|
+
try:
|
|
56
|
+
intent = db.get_intent(UUID(intent_id))
|
|
57
|
+
if not intent:
|
|
58
|
+
click.echo(f"Intent '{intent_id}' not found.", err=True)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
click.echo(f"\nIntent: {intent.intent_id}")
|
|
62
|
+
click.echo("-" * 60)
|
|
63
|
+
click.echo(f" Job ID: {intent.job_id}")
|
|
64
|
+
click.echo(f" Chain ID: {intent.chain_id}")
|
|
65
|
+
click.echo(f" Status: {intent.status.value}")
|
|
66
|
+
click.echo(f" Signer: {intent.signer_address}")
|
|
67
|
+
click.echo(f" To: {intent.to_address}")
|
|
68
|
+
click.echo(f" Value: {intent.value_wei} wei")
|
|
69
|
+
click.echo(f" Idempotency Key: {intent.idempotency_key}")
|
|
70
|
+
click.echo(f" Min Confirmations: {intent.min_confirmations}")
|
|
71
|
+
if intent.deadline_ts:
|
|
72
|
+
click.echo(f" Deadline: {intent.deadline_ts}")
|
|
73
|
+
if intent.retry_after:
|
|
74
|
+
click.echo(f" Retry After: {intent.retry_after}")
|
|
75
|
+
if intent.retry_count > 0:
|
|
76
|
+
click.echo(f" Retry Count: {intent.retry_count}")
|
|
77
|
+
if intent.claim_token:
|
|
78
|
+
click.echo(f" Claim Token: {intent.claim_token}")
|
|
79
|
+
click.echo(f" Created: {intent.created_at}")
|
|
80
|
+
click.echo(f" Updated: {intent.updated_at}")
|
|
81
|
+
|
|
82
|
+
attempts = db.get_attempts_for_intent(intent.intent_id)
|
|
83
|
+
if attempts:
|
|
84
|
+
click.echo(f"\n Attempts ({len(attempts)}):")
|
|
85
|
+
for att in attempts:
|
|
86
|
+
status = att.status.value
|
|
87
|
+
tx_hash = att.tx_hash[:20] + "..." if att.tx_hash else "N/A"
|
|
88
|
+
click.echo(f" - {att.attempt_id}: {status} (tx: {tx_hash})")
|
|
89
|
+
click.echo()
|
|
90
|
+
finally:
|
|
91
|
+
db.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@intents.command("cancel")
|
|
95
|
+
@click.argument("intent_id")
|
|
96
|
+
@click.option("--force", is_flag=True, help="Force cancel even if in-flight")
|
|
97
|
+
@click.option("--config", "config_path", default=None, help="Path to config.yaml")
|
|
98
|
+
def intents_cancel(intent_id: str, force: bool, config_path: str | None) -> None:
|
|
99
|
+
"""Cancel an intent."""
|
|
100
|
+
from uuid import UUID
|
|
101
|
+
|
|
102
|
+
db = get_db(config_path)
|
|
103
|
+
try:
|
|
104
|
+
intent = db.get_intent(UUID(intent_id))
|
|
105
|
+
if not intent:
|
|
106
|
+
click.echo(f"Intent '{intent_id}' not found.", err=True)
|
|
107
|
+
sys.exit(1)
|
|
108
|
+
|
|
109
|
+
if intent.status.value in ("confirmed", "abandoned"):
|
|
110
|
+
click.echo(f"Intent is already {intent.status.value}.", err=True)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
if intent.status.value in ("sending", "pending") and not force:
|
|
114
|
+
click.echo(
|
|
115
|
+
f"Intent is {intent.status.value}. Use --force to cancel in-flight intents.",
|
|
116
|
+
err=True,
|
|
117
|
+
)
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
if db.abandon_intent(UUID(intent_id)):
|
|
121
|
+
click.echo(f"Intent '{intent_id}' cancelled.")
|
|
122
|
+
else:
|
|
123
|
+
click.echo("Failed to cancel intent.", err=True)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
finally:
|
|
126
|
+
db.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register(main) -> None:
|
|
130
|
+
main.add_command(intents)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Developer utilities for individual jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from brawny.cli.helpers import print_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def job() -> None:
|
|
15
|
+
"""Developer utilities for a single job."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@job.command("run")
|
|
20
|
+
@click.argument("job_id")
|
|
21
|
+
@click.option("--at-block", type=int, help="Run check/build against this block")
|
|
22
|
+
@click.option("--dry-run", is_flag=True, help="Run check only (skip build_intent)")
|
|
23
|
+
@click.option(
|
|
24
|
+
"--jobs-module",
|
|
25
|
+
"jobs_modules",
|
|
26
|
+
multiple=True,
|
|
27
|
+
help="Additional job module(s) to load",
|
|
28
|
+
)
|
|
29
|
+
@click.option("--config", "config_path", default="./config.yaml", help="Path to config.yaml")
|
|
30
|
+
def job_run(
|
|
31
|
+
job_id: str,
|
|
32
|
+
at_block: int | None,
|
|
33
|
+
dry_run: bool,
|
|
34
|
+
jobs_modules: tuple[str, ...],
|
|
35
|
+
config_path: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Run check/build for a single job without sending transactions."""
|
|
38
|
+
import time
|
|
39
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
|
40
|
+
|
|
41
|
+
from brawny._context import _job_ctx, _current_job
|
|
42
|
+
from brawny.config import Config
|
|
43
|
+
from brawny.db import create_database
|
|
44
|
+
from brawny.alerts.contracts import ContractSystem
|
|
45
|
+
from brawny.jobs.kv import DatabaseJobKVStore
|
|
46
|
+
from brawny.jobs.registry import get_registry
|
|
47
|
+
from brawny.jobs.discovery import auto_discover_jobs, discover_jobs
|
|
48
|
+
from brawny.logging import get_logger, setup_logging
|
|
49
|
+
from brawny.model.enums import LogFormat
|
|
50
|
+
from brawny.model.types import BlockInfo, JobContext, idempotency_key
|
|
51
|
+
from brawny._rpc import RPCManager
|
|
52
|
+
from brawny.scripting import set_job_context
|
|
53
|
+
|
|
54
|
+
if not config_path or not os.path.exists(config_path):
|
|
55
|
+
click.echo(
|
|
56
|
+
f"Config file is required for job run and was not found: {config_path}",
|
|
57
|
+
err=True,
|
|
58
|
+
)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
config = Config.from_yaml(config_path)
|
|
62
|
+
config, overrides = config.apply_env_overrides()
|
|
63
|
+
|
|
64
|
+
log_level = os.environ.get("BRAWNY_LOG_LEVEL", "INFO")
|
|
65
|
+
setup_logging(log_level, LogFormat.JSON, config.chain_id)
|
|
66
|
+
log = get_logger(__name__)
|
|
67
|
+
log.info(
|
|
68
|
+
"config.loaded",
|
|
69
|
+
path=config_path,
|
|
70
|
+
overrides=overrides,
|
|
71
|
+
config=config.redacted_dict(),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
db = create_database(
|
|
75
|
+
config.database_url,
|
|
76
|
+
pool_size=config.database_pool_size,
|
|
77
|
+
pool_max_overflow=config.database_pool_max_overflow,
|
|
78
|
+
pool_timeout=config.database_pool_timeout_seconds,
|
|
79
|
+
circuit_breaker_failures=config.db_circuit_breaker_failures,
|
|
80
|
+
circuit_breaker_seconds=config.db_circuit_breaker_seconds,
|
|
81
|
+
)
|
|
82
|
+
db.connect()
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
rpc = RPCManager.from_config(config)
|
|
86
|
+
contract_system = ContractSystem(rpc, config)
|
|
87
|
+
|
|
88
|
+
modules = list(jobs_modules)
|
|
89
|
+
if modules:
|
|
90
|
+
discover_jobs(modules)
|
|
91
|
+
else:
|
|
92
|
+
auto_discover_jobs()
|
|
93
|
+
|
|
94
|
+
registry = get_registry()
|
|
95
|
+
job_obj = registry.get(job_id)
|
|
96
|
+
if job_obj is None:
|
|
97
|
+
click.echo(f"Job '{job_id}' not found. Did you register it?", err=True)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
if at_block is not None:
|
|
101
|
+
block_number = at_block
|
|
102
|
+
else:
|
|
103
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
|
|
104
|
+
number_future = executor.submit(rpc.get_block_number, timeout=5)
|
|
105
|
+
try:
|
|
106
|
+
block_number = number_future.result(timeout=10)
|
|
107
|
+
except FuturesTimeout:
|
|
108
|
+
click.echo("Timed out fetching latest block number.", err=True)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
block_hash = "0x0"
|
|
111
|
+
timestamp = 0
|
|
112
|
+
|
|
113
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
|
|
114
|
+
block_future = executor.submit(rpc.get_block, block_number, False, 5)
|
|
115
|
+
try:
|
|
116
|
+
block_data = block_future.result(timeout=10)
|
|
117
|
+
except FuturesTimeout:
|
|
118
|
+
block_data = None
|
|
119
|
+
|
|
120
|
+
if block_data:
|
|
121
|
+
block_hash = block_data.get("hash", "")
|
|
122
|
+
if hasattr(block_hash, "hex"):
|
|
123
|
+
block_hash = f"0x{block_hash.hex()}"
|
|
124
|
+
timestamp = block_data.get("timestamp", 0)
|
|
125
|
+
else:
|
|
126
|
+
click.echo(
|
|
127
|
+
f"Warning: timed out loading block {block_number}; using fallback data.",
|
|
128
|
+
err=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
block = BlockInfo(
|
|
132
|
+
chain_id=config.chain_id,
|
|
133
|
+
block_number=block_number,
|
|
134
|
+
block_hash=block_hash,
|
|
135
|
+
timestamp=timestamp,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
job_config = db.get_job(job_id)
|
|
139
|
+
ctx = JobContext(
|
|
140
|
+
block=block,
|
|
141
|
+
rpc=rpc,
|
|
142
|
+
logger=log.bind(job_id=job_id),
|
|
143
|
+
state={},
|
|
144
|
+
job_config=job_config,
|
|
145
|
+
kv=DatabaseJobKVStore(db, job_id),
|
|
146
|
+
job_id=job_id,
|
|
147
|
+
contract_system=contract_system,
|
|
148
|
+
hook_name="check",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _call_check():
|
|
152
|
+
# Set contextvars for implicit context
|
|
153
|
+
ctx_token = _job_ctx.set(ctx)
|
|
154
|
+
job_token = _current_job.set(job_obj)
|
|
155
|
+
set_job_context(True)
|
|
156
|
+
try:
|
|
157
|
+
return job_obj.check()
|
|
158
|
+
finally:
|
|
159
|
+
set_job_context(False)
|
|
160
|
+
_job_ctx.reset(ctx_token)
|
|
161
|
+
_current_job.reset(job_token)
|
|
162
|
+
|
|
163
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
|
|
164
|
+
start = time.time()
|
|
165
|
+
future = executor.submit(_call_check)
|
|
166
|
+
try:
|
|
167
|
+
trigger = future.result(timeout=job_obj.check_timeout_seconds)
|
|
168
|
+
except FuturesTimeout:
|
|
169
|
+
click.echo(
|
|
170
|
+
f"check() timed out after {job_obj.check_timeout_seconds}s",
|
|
171
|
+
err=True,
|
|
172
|
+
)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
elapsed = time.time() - start
|
|
176
|
+
|
|
177
|
+
if not trigger:
|
|
178
|
+
click.echo(f"No trigger (check completed in {elapsed:.2f}s).")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
idem_parts = list(trigger.idempotency_parts) or [block.block_number]
|
|
182
|
+
idem_key = idempotency_key(job_id, *idem_parts)
|
|
183
|
+
|
|
184
|
+
click.echo("\nTrigger:")
|
|
185
|
+
print_json(
|
|
186
|
+
{
|
|
187
|
+
"reason": trigger.reason,
|
|
188
|
+
"tx_required": trigger.tx_required,
|
|
189
|
+
"idempotency_parts": idem_parts,
|
|
190
|
+
"idempotency_key": idem_key,
|
|
191
|
+
"check_seconds": round(elapsed, 3),
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if dry_run or not trigger.tx_required:
|
|
196
|
+
if dry_run:
|
|
197
|
+
click.echo("Dry run enabled; skipping build_intent.")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
ctx_build = JobContext(
|
|
201
|
+
block=block,
|
|
202
|
+
rpc=rpc,
|
|
203
|
+
logger=log.bind(job_id=job_id),
|
|
204
|
+
state={"trigger": trigger},
|
|
205
|
+
job_config=job_config,
|
|
206
|
+
kv=DatabaseJobKVStore(db, job_id),
|
|
207
|
+
job_id=job_id,
|
|
208
|
+
contract_system=contract_system,
|
|
209
|
+
hook_name="build_intent",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _call_build():
|
|
213
|
+
# Set contextvars for implicit context
|
|
214
|
+
ctx_token = _job_ctx.set(ctx_build)
|
|
215
|
+
job_token = _current_job.set(job_obj)
|
|
216
|
+
set_job_context(True)
|
|
217
|
+
try:
|
|
218
|
+
return job_obj.build_intent(trigger)
|
|
219
|
+
finally:
|
|
220
|
+
set_job_context(False)
|
|
221
|
+
_job_ctx.reset(ctx_token)
|
|
222
|
+
_current_job.reset(job_token)
|
|
223
|
+
|
|
224
|
+
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="job_dev") as executor:
|
|
225
|
+
future = executor.submit(_call_build)
|
|
226
|
+
try:
|
|
227
|
+
spec = future.result(timeout=job_obj.build_timeout_seconds)
|
|
228
|
+
except FuturesTimeout:
|
|
229
|
+
click.echo(
|
|
230
|
+
f"build_intent() timed out after {job_obj.build_timeout_seconds}s",
|
|
231
|
+
err=True,
|
|
232
|
+
)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
click.echo("\nTxIntentSpec:")
|
|
236
|
+
print_json(
|
|
237
|
+
{
|
|
238
|
+
"signer_address": spec.signer_address,
|
|
239
|
+
"to_address": spec.to_address,
|
|
240
|
+
"data": spec.data,
|
|
241
|
+
"value_wei": spec.value_wei,
|
|
242
|
+
"gas_limit": spec.gas_limit,
|
|
243
|
+
"max_fee_per_gas": spec.max_fee_per_gas,
|
|
244
|
+
"max_priority_fee_per_gas": spec.max_priority_fee_per_gas,
|
|
245
|
+
"min_confirmations": spec.min_confirmations,
|
|
246
|
+
"deadline_seconds": spec.deadline_seconds,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
finally:
|
|
250
|
+
db.close()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def register(main) -> None:
|
|
254
|
+
main.add_command(job)
|