Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ MERGEWORK_ADMIN_LOGINS=
MERGEWORK_GITHUB_ACCEPTED_LABELERS=
MERGEWORK_ADMIN_TOKEN=
MERGEWORK_COOKIE_SECRET=
MERGEWORK_TREASURY_EXECUTOR_ENABLED=0
MERGEWORK_TREASURY_EXECUTOR_INTERVAL_SECONDS=300
MERGEWORK_TREASURY_EXECUTOR_BATCH_LIMIT=25
142 changes: 142 additions & 0 deletions app/treasury_executor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import annotations

from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any

from sqlalchemy import select

from app.db import session_scope
from app.github_issue_finalization import finalize_created_bounty_issue
from app.ledger.service import LedgerError
from app.models import TreasuryProposal, utc_now
from app.treasury import (
execute_treasury_proposal,
proposal_to_dict,
record_proposal_result_field,
)

Finalizer = Callable[..., dict[str, Any]]


def _db_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)


def due_treasury_proposal_ids(db_url: str, *, limit: int = 25) -> list[int]:
now = _db_utc(utc_now())
with session_scope(db_url) as session:
return [
int(proposal_id)
for proposal_id in session.scalars(
select(TreasuryProposal.id)
.where(
TreasuryProposal.status == "pending",
TreasuryProposal.executes_after <= now,
)
.order_by(TreasuryProposal.executes_after.asc(), TreasuryProposal.id.asc())
.limit(limit)
).all()
]


def execute_treasury_proposal_with_finalization(
db_url: str,
*,
proposal_id: int,
executed_by: str,
github_issue_token: str,
public_base_url: str,
finalizer: Finalizer | None = None,
) -> dict[str, Any]:
with session_scope(db_url) as session:
proposal = execute_treasury_proposal(
session, proposal_id=proposal_id, executed_by=executed_by
)
response = proposal_to_dict(proposal)
if response["action"] != "create_bounty":
return response
bounty = response.get("result", {}).get("bounty")
if not isinstance(bounty, dict):
return response
issue_finalizer = finalizer or finalize_created_bounty_issue
try:
finalization = issue_finalizer(
github_token=github_issue_token,
public_base_url=public_base_url,
bounty=bounty,
)
except Exception as exc:
finalization = {
"status": "failed",
"reason": f"github issue finalization failed: {type(exc).__name__}",
}
with session_scope(db_url) as session:
proposal = record_proposal_result_field(
session,
proposal_id=proposal_id,
field="github_issue_finalization",
value=finalization,
)
return proposal_to_dict(proposal)


def execute_due_treasury_proposals(
db_url: str,
*,
github_issue_token: str,
public_base_url: str,
executed_by: str = "treasury-executor",
limit: int = 25,
finalizer: Finalizer | None = None,
) -> dict[str, Any]:
proposal_ids = due_treasury_proposal_ids(db_url, limit=limit)
results: list[dict[str, Any]] = []
executed = 0
failed = 0
for proposal_id in proposal_ids:
action = "unknown"
with session_scope(db_url) as session:
proposal = session.get(TreasuryProposal, proposal_id)
if proposal is not None:
action = proposal.action
try:
response = execute_treasury_proposal_with_finalization(
db_url,
proposal_id=proposal_id,
executed_by=executed_by,
github_issue_token=github_issue_token,
public_base_url=public_base_url,
finalizer=finalizer,
)
except LedgerError as exc:
failed += 1
results.append(
{
"proposal_id": proposal_id,
"action": action,
"status": "failed",
"error": str(exc),
}
)
continue
executed += 1
item = {
"proposal_id": int(response["id"]),
"action": str(response["action"]),
"status": str(response["status"]),
"result": response["result"],
}
finalization = response.get("result", {}).get("github_issue_finalization")
if finalization is not None:
item["github_issue_finalization"] = finalization
results.append(item)
return {
"type": "treasury_executor_run",
"attempted": len(proposal_ids),
"executed": executed,
"failed": failed,
"results": results,
}
45 changes: 11 additions & 34 deletions app/treasury_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@
from sqlalchemy import select

from app.db import session_scope
from app.github_issue_finalization import finalize_created_bounty_issue
from app.ledger.service import LedgerError
from app.models import TreasuryProposal
from app.path_params import SQLITE_INTEGER_MAX
from app.treasury import (
challenge_to_dict,
create_treasury_challenge,
execute_treasury_proposal,
proposal_to_dict,
propose_treasury_action,
record_proposal_result_field,
treasury_status,
)
from app.treasury_executor import execute_treasury_proposal_with_finalization


def _positive_proposal_id(proposal_id: int) -> int:
Expand Down Expand Up @@ -102,37 +100,16 @@ def api_execute_treasury_proposal(
admin_login: str = Depends(require_admin_token),
) -> dict[str, Any]:
proposal_id = _positive_proposal_id(proposal_id)
with session_scope(db_url) as session:
try:
proposal = execute_treasury_proposal(
session, proposal_id=proposal_id, executed_by=admin_login
)
response = proposal_to_dict(proposal)
except LedgerError as exc:
raise _proposal_error(exc) from exc
if response["action"] == "create_bounty":
bounty = response.get("result", {}).get("bounty")
if isinstance(bounty, dict):
try:
finalization = finalize_created_bounty_issue(
github_token=github_issue_token,
public_base_url=public_base_url,
bounty=bounty,
)
except Exception as exc:
finalization = {
"status": "failed",
"reason": f"github issue finalization failed: {type(exc).__name__}",
}
with session_scope(db_url) as session:
proposal = record_proposal_result_field(
session,
proposal_id=proposal_id,
field="github_issue_finalization",
value=finalization,
)
response = proposal_to_dict(proposal)
return response
try:
return execute_treasury_proposal_with_finalization(
db_url,
proposal_id=proposal_id,
executed_by=admin_login,
github_issue_token=github_issue_token,
public_base_url=public_base_url,
)
except LedgerError as exc:
raise _proposal_error(exc) from exc

@app.post("/api/v1/treasury/proposals/{proposal_id}/challenges")
async def api_create_treasury_challenge(
Expand Down
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ services:
sleep 86400
done

treasury-executor:
build: .
restart: unless-stopped
depends_on:
- app
env_file:
- .env
volumes:
- /srv/mergework/data:/srv/mergework/data
command: python -m scripts.treasury_executor

volumes:
caddy-data:
caddy-config:
44 changes: 41 additions & 3 deletions docs/admin-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
a single-award bounty.
7. Use `/admin` or `POST /api/v1/bounties` with an admin token. This creates
a public treasury proposal.
8. Execute the proposal after the 24-hour delay. Multi-award bounties reserve
8. Execute the proposal after the 24-hour delay, or let the enabled production
treasury executor execute it. Multi-award bounties reserve
`reward_mrwk * max_awards` when the proposal executes.
9. If `MERGEWORK_GITHUB_ISSUE_TOKEN` is configured, execution adds
`mrwk:bounty` and posts the `Reserved on MergeWork` claims-open comment. If
Expand Down Expand Up @@ -88,6 +89,42 @@ This governance surface makes normal app-path treasury movement public,
delayed, capped, and challengeable. It does not prevent direct server or
database bypass by an operator with production access.

### Production Treasury Executor

Production can run the `treasury-executor` Docker Compose service to execute
eligible treasury proposals without a maintainer being online at the exact
`executes_after` time. The service uses the production `.env`, the same
database volume as the app, and the same execution path as the manual admin
route.

Enable it only in the production `.env`:

```env
MERGEWORK_TREASURY_EXECUTOR_ENABLED=1
MERGEWORK_TREASURY_EXECUTOR_INTERVAL_SECONDS=300
MERGEWORK_TREASURY_EXECUTOR_BATCH_LIMIT=25
```

Deploy or restart the service with Docker Compose after editing production
`.env`. Do not run the executor from a local checkout or local `.env`.

```bash
docker compose up -d treasury-executor
docker compose logs -f treasury-executor
```

Each pass executes due pending proposals oldest-first up to the batch limit. If
one proposal fails, the executor logs that failure and continues with later due
proposals. It does not execute proposals before the 24-hour delay, and blocking
challenges still prevent execution through the normal treasury rules.

For due `create_bounty` proposals, successful execution should also finalize
the GitHub issue through the #630 path. Verify `result.github_issue_finalization`
on the proposal and confirm the issue has both `mrwk:bounty` and the
`Reserved on MergeWork` claims-open comment. If finalization is skipped, failed,
or partial, use the manual fallback rules above after confirming the public
bounty row exists.

## Accept Work

### PR Bounties
Expand Down Expand Up @@ -149,7 +186,8 @@ Manual payout checklist:

1. Verify the public proof and wallet address.
2. Propose payment through `POST /api/v1/bounties/{id}/pay`.
3. Confirm the public proposal, wait for the delay, then execute it.
3. Confirm the public proposal, wait for the delay, then execute it manually or
let the enabled production treasury executor execute it.
4. Confirm the explorer/API shows the proof.
5. Add `mrwk:paid` to the paid comment or submission when possible.
6. Add `mrwk:paid` to the bounty issue only after all awards are exhausted or
Expand Down Expand Up @@ -283,7 +321,7 @@ the repository and restart Docker Compose.
- Database: `/srv/mergework/data/mergework.sqlite3`.
- Backups: `/srv/mergework/backups`.
- Health check: `GET /health`.
- Logs: `docker compose logs -f app caddy`.
- Logs: `docker compose logs -f app caddy treasury-executor`.

## Pre-Bounty Readiness

Expand Down
6 changes: 6 additions & 0 deletions scripts/docs_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@
"Internal ledger accounts use the same account response shape",
("Treasury and reserve balances change as bounties are reserved, paid, and released."),
],
"docs/admin-runbook.md": [
"MERGEWORK_TREASURY_EXECUTOR_ENABLED=1",
"uses the production `.env`",
"docker compose logs -f treasury-executor",
"Verify `result.github_issue_finalization`",
],
}
LINK_RE = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
DOCS_ISSUE_TEMPLATE = ".github/ISSUE_TEMPLATE/docs.yml"
Expand Down
Loading
Loading