Compare commits

...
3 Commits
12 changed files with 386 additions and 44 deletions

No files matched your search

+11 -3
View File
@@ -51,12 +51,20 @@ SCAN_HOURS=0,8,16
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
SCAN_RUN_ON_START=1
# — Distribusi profit-share harian (service distribute) —
# Jam (lokal, TZ) run harian. `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi
# .env: di stack ia dibaca dari file `.env` yang di-bind dari NFS
# — Distribusi profit-share (service distribute) —
# Jam (lokal, TZ) run. Jadwal MINGGUAN (V37): tiap DISTRIBUTE_WEEKDAY (nama
# `sat`/`mon`.. atau 0-6; Senin=0, default `sat`) jam DISTRIBUTE_HOUR, plus satu
# one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD; default baked `2026-08-17`). Contoh
# produksi:
# DISTRIBUTE_FIRST_RUN=2026-08-17 # putaran pertama Senin 17 Agt
# DISTRIBUTE_WEEKDAY=sat # lalu tiap Sabtu
# `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi .env: di stack ia dibaca
# dari file `.env` yang di-bind dari NFS
# (`/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env`,
# dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal.
DISTRIBUTE_HOUR=10
DISTRIBUTE_WEEKDAY=sat
DISTRIBUTE_FIRST_RUN=2026-08-17
DISTRIBUTE_MAX_ATTEMPTS=3
# Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan
# pembayaran. --dry-run tetap jalan walau off.
+9 -9
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34).
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters on a weekly schedule: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34).
## Run
@@ -11,11 +11,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` weekly at `DISTRIBUTE_WEEKDAY` default `sat` + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_images.py test_claim.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37) is pure calendar logic — weekly schedule boundary + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
## Key facts
@@ -29,8 +29,8 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`.
- The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next day.
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
@@ -44,11 +44,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`) → `next_window` = +24 jam; tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; reward = selisih saldo (setelah−sebelum, settle); fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, temp DB).
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
- `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`.
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
- `distribute_loop.py` — scheduler harian dalam container stack (service `distribute`): tunggu `DISTRIBUTE_HOUR` (lokal via `TZ`), panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba besok; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `distribute_loop.py` — scheduler mingguan dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `sat`) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
+8 -2
View File
@@ -54,7 +54,7 @@ cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID)
web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only)
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}`, rank global, cap 500
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ` — via `config.py` (`.env`)
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (dashboard banner V38; web service di stack menerima TZ + jadwal) — via `config.py` (`.env`)
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_balance` → body {code:`vex.token`, account, symbol:`VEX`} → `["X.XXXX VEX"]`
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_stats` → {supply, max_supply, issuer} (cek precision)
api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via pyntelope) → txid
@@ -64,7 +64,7 @@ db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, t
db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain)
cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign
web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`)
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_WEEKDAY` (default `sat`), `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`; env override), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`)
cmd: `./venv/bin/python telegram.py` — modul notifikasi (⊥ CLI); `TELEGRAM_BOT_TOKEN` + daftar chat kosong → kanal no-op senyap; `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal
db: table `claim_runs` (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via `db.py`; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola)
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows` → body {json:true, code:`vexcore`, scope:`vexcore`, table:`producers`, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim
@@ -72,6 +72,7 @@ api: aksi `vexcore::claimrewards` (owner name) sign pyntelope → `push_transact
cmd: `./venv/bin/python claim_loop.py` → scheduler klaim harian (image `databisnisid-claim`, service `claim`); `claim.py` modul logika klaim (⊥ CLI)
env: `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT` — via `config.py` (`.env`); key/Telegram dari bind NFS `config.env` di stack (sama dgn distribute)
cmd: `./venv/bin/python test_claim.py` — oracle V32..V34 (mock chain+pyntelope, temp DB)
cmd: `./venv/bin/python test_distribute_loop.py` — oracle V37 (logika kalender mingguan + one-off, ⊥ jaringan/DB)
## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -109,6 +110,8 @@ V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay
V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal
V36: notifikasi klaim reward (sukses `notify_claim` & gagal `notify_claim_failure`) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke `failed`)
V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` (nama `mon`..`sun` atau 0-6; default `sat`) jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`; default baked `2026-08-17`) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op; `distribute_loop.main` baca jadwal via `config` (⊥ `os.getenv` sendiri) agar konsisten dgn dashboard V38
V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (`DISTRIBUSI BERIKUTNYA` + tanggal id-ID `SENIN · 17 AGUSTUS 2026 · 10:00 WIB`); baris berulang `SETIAP SABTU · 10:00` HANYA utk putaran mingguan — ⊥ tampil saat event berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten; `_is_first_run_next`); setelah launch lewat → baris muncul lagi; logika boundary MIRROR `distribute_loop.next_boundary` (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`/`TZ` (interpolasi .env)
## §T — Tasks
id|status|task|cites
@@ -154,6 +157,8 @@ T39|x|telegram.py: `notify_claim` (reward+fee) + `notify_claim_failure` + hook d
T40|x|test_claim.py oracle (mock chain+pyntelope, temp DB: jendela, math fee, siklus penuh, retry fee, resume, reward 0) + test_images.py += Dockerfile.claim & modul claim/claim_loop|V31,V32,V33,V34,I.db
T41|x|kill-switch distribusi: `DISTRIBUTE_ENABLED` (config default false) + gate `run_distribution` (non-dry-run no-op: exit 0, ⊥ sign/tulis/notif; dry-run tetap) + env compose `${DISTRIBUTE_ENABLED:-false}` + oracle (default off, disabled no-op, disabled+dry-run)|V35,I.env
T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env
T43|x|jadwal mingguan: `distribute_loop.py` `next_boundary` = {one-off `DISTRIBUTE_FIRST_RUN` @ jam (jika depan)} ∪ {mingguan `DISTRIBUTE_WEEKDAY` @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`) + env compose + .env.example + oracle `test_distribute_loop.py`|V37,I.env
T44|x|dashboard banner BERITA jadwal distribusi berikutnya: `dashboard.py` mirror `_next_boundary` (⊥ impor lintas-image, V31) + `_next_schedule`/`_fmt_schedule` (id-ID) + `_recurrence_label` (baris berulang hanya utk mingguan, ⊥ saat first-run, `_is_first_run_next`); section `.news` di `index.html` + CSS; env compose web += `TZ`/`DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env
## §B — Bug log
id|date|cause|fix
@@ -162,3 +167,4 @@ B2|2026-08-05|`ESCAPE '\'` valid di SQLite tapi memutus literal string MySQL (`'
B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []` gagal di mysql|`db.query` bungkus `list()` → tipe seragam (V17)
B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31
B5|2026-08-06|`DISTRIBUTE_ENABLED` pakai `not in ('1','true','yes')` → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke `in (...)` — default off (V35)
B6|2026-08-06|mirror `_parse_weekday` di dashboard memakai kunci nama hari id-ID (`sabtu`) tapi config default `sat` (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN `mon..sun` (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard
+8
View File
@@ -62,6 +62,14 @@ DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3'))
# `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis).
DISTRIBUTE_ENABLED = os.getenv(
'DISTRIBUTE_ENABLED', 'false').lower() in ('1', 'true', 'yes')
# Jadwal mingguan (V37): hari `DISTRIBUTE_WEEKDAY` (nama `sat`/`mon`.. atau
# 0-6, default `sat`) jam `DISTRIBUTE_HOUR`; plus satu one-off
# `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`). Dipakai distribute_loop (via config,
# konsisten dgn dashboard V38). Default baked = launch pertama 2026-08-17
# (Senin) 10:00; env tetap override. Setelah tanggal lewat → otomatis hanya
# mingguan (batas skip event masa lalu).
DISTRIBUTE_WEEKDAY = os.getenv('DISTRIBUTE_WEEKDAY', 'sat')
DISTRIBUTE_FIRST_RUN = os.getenv('DISTRIBUTE_FIRST_RUN', '2026-08-17')
# ————————————————— Notifikasi Telegram (V30/V36) —————————————————
# Bot token tunggal; dua daftar chat id (CSV): KOMUNITAS (`..._COMMUNITY_...`)
+91 -1
View File
@@ -7,13 +7,14 @@ Konfigurasi dari env/`.env`.
"""
import time
from datetime import datetime
from datetime import datetime, timedelta
import requests
from flask import Flask, abort, jsonify, render_template, request
import db
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
PAGE_SIZE, TARGET_BP)
SEARCH_CAP = 500
@@ -66,6 +67,92 @@ def shortdate(value):
return '—'
# ————— Jadwal distribusi berikutnya (V38) —————
# Banner BERITA di dashboard menampilkan run distribusi berikutnya. Logika
# boundary sengaja mirror `distribute_loop.next_boundary` (⊥ impor lintas-image,
# V31) — oracle test_dashboard menjaga keduanya tak melenceng.
_SCHED_DAYS = ('SENIN', 'SELASA', 'RABU', 'KAMIS', 'JUMAT', 'SABTU', 'MINGGU')
# Parser pakai singkatan Inggris (sama dgn distribute_loop.WEEKDAYS); display
# id-ID via `_SCHED_DAYS` (index = weekday Python, Senin=0).
_SCHED_WEEKDAYS = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6}
_SCHED_MONTHS = ('JANUARI', 'FEBRUARI', 'MARET', 'APRIL', 'MEI', 'JUNI',
'JULI', 'AGUSTUS', 'SEPTEMBER', 'OKTOBER', 'NOVEMBER',
'DESEMBER')
def _parse_weekday(value):
"""Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0)."""
v = str(value).strip().lower()
if v in _SCHED_WEEKDAYS:
return _SCHED_WEEKDAYS[v]
if v.isdigit() and 0 <= int(v) <= 6:
return int(v)
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
def _parse_first_run(value):
"""`YYYY-MM-DD` → date; kosong → None (⊥ one-off)."""
if not value:
return None
return datetime.strptime(str(value).strip(), '%Y-%m-%d').date()
def _next_boundary(now, hour, weekday, first_run=None):
"""Mirror `distribute_loop.next_boundary`: mingguan + one-off, Sabtu
sebelum launch dilewati; pilih event terdekat > `now`."""
days_ahead = (weekday - now.weekday()) % 7
weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0)
+ timedelta(days=days_ahead))
if weekly <= now:
weekly += timedelta(days=7)
if first_run is None:
return weekly
start = datetime(first_run.year, first_run.month, first_run.day, hour=hour)
candidates = []
if start >= now:
candidates.append(start)
if weekly >= start:
candidates.append(weekly)
return min(candidates)
def _next_schedule(now=None):
"""Run distribusi berikutnya (local TZ). `now` bisa di-inject utk oracle."""
return _next_boundary(now or datetime.now(), DISTRIBUTE_HOUR,
_parse_weekday(DISTRIBUTE_WEEKDAY),
_parse_first_run(DISTRIBUTE_FIRST_RUN))
def _fmt_schedule(dt):
"""`SENIN · 17 AGUSTUS 2026 · 10:00 WIB` (id-ID, uppercase)."""
tz = dt.astimezone().tzname() or ''
return (f'{_SCHED_DAYS[dt.weekday()]} · {dt.day} '
f'{_SCHED_MONTHS[dt.month - 1]} {dt.year} · '
f'{dt.hour:02d}:{dt.minute:02d} {tz}').strip()
def _recurrence_label(now=None):
"""Baris berulang: `SETIAP SABTU · 10:00`. ⊥ tampil saat event berikutnya
= one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten);
baris hanya utk putaran mingguan (muncul lagi setelah launch lewat)."""
if _is_first_run_next(now):
return None
return (f'SETIAP {_SCHED_DAYS[_parse_weekday(DISTRIBUTE_WEEKDAY)]}'
f' · {DISTRIBUTE_HOUR:02d}:00')
def _is_first_run_next(now=None):
"""True bila event distribusi berikutnya = one-off `DISTRIBUTE_FIRST_RUN`
(launch), bukan putaran mingguan."""
now = now or datetime.now()
first = _parse_first_run(DISTRIBUTE_FIRST_RUN)
if first is None:
return False
start = datetime(first.year, first.month, first.day, hour=DISTRIBUTE_HOUR)
return start >= now and _next_schedule(now) == start
def _get_liquid_vex():
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
@@ -193,6 +280,7 @@ def index():
page = min(page, total_pages)
rows = _db_rows(page, q)
_now = datetime.now()
return render_template(
'index.html',
bp=TARGET_BP,
@@ -206,6 +294,8 @@ def index():
scanned=scanned,
q=q,
filtered_count=filtered_count,
next_schedule=_fmt_schedule(_next_schedule(_now)),
schedule_recur=_recurrence_label(_now),
)
+62 -22
View File
@@ -1,40 +1,76 @@
"""Jadwal distribusi harian: jalankan distribute.py pada jam DISTRIBUTE_HOUR.
"""Jadwal distribusi mingguan: jalankan distribute.py pada hari `DISTRIBUTE_WEEKDAY`
jam `DISTRIBUTE_HOUR`, plus satu one-off `DISTRIBUTE_FIRST_RUN` (YYYY-MM-DD,
default baked `2026-08-17` via config).
Menunggu hingga batas `DISTRIBUTE_HOUR` berikutnya (waktu lokal via `TZ`)
lalu memanggil `distribute.main()`. Loop tak pernah keluar — service dijaga
hidup oleh restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke hari
berikutnya (⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise
RuntimeError yang jelas — ditangkap, dicetak, dicoba lagi besok.
Contoh produksi: `DISTRIBUTE_FIRST_RUN=2026-08-17` (Senin) lalu `DISTRIBUTE_WEEKDAY=sat`
→ putaran pertama Senin 17 Agt 10:00, berikutnya tiap Sabtu 10:00.
Menunggu hingga batas terjadwal berikutnya (waktu lokal via `TZ`) lalu memanggil
`distribute.main()`. Loop tak pernah keluar — service dijaga hidup oleh
restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke jadwal berikutnya
(⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise RuntimeError yang
jelas — ditangkap, dicetak, dicoba lagi di jadwal berikutnya.
Berbeda dengan scan: ⊥ ada "distribusi-awal" saat start (SCAN_RUN_ON_START) —
membayar di container start berisiko memakai snapshot basi; distribusi hanya
berjalan pada jam terjadwal.
berjalan pada jadwal.
Scheduler helper (`next_boundary`, `_wait_db`) sengaja diduplikasi dari
`scan_loop.py` (bukan di-import) karena kedua image Docker terpisah:
`Dockerfile.dist` ⊥ membawa `scan_loop.py`/`get_voters.py`.
"""
import os
import time
from datetime import datetime, timedelta
import config
import db
import distribute
WEEKDAYS = {
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6,
}
def next_boundary(now, hours):
"""datetime berikutnya pada jam-jam `hours` yang lebih besar dari `now`.
Sama dengan `scan_loop.next_boundary` (mirror agar image dist mandiri).
"""
for h in hours:
cand = now.replace(hour=h, minute=0, second=0, microsecond=0)
if cand > now:
return cand
# semua jam hari ini sudah lewat → besok jam pertama
nxt = now + timedelta(days=1)
return nxt.replace(hour=hours[0], minute=0, second=0, microsecond=0)
def parse_weekday(value):
"""Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0)."""
v = str(value).strip().lower()
if v in WEEKDAYS:
return WEEKDAYS[v]
if v.isdigit() and 0 <= int(v) <= 6:
return int(v)
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
def parse_first_run(value):
"""`YYYY-MM-DD` → date; kosong → None (⊥ one-off)."""
if not value:
return None
return datetime.strptime(str(value).strip(), '%Y-%m-%d').date()
def next_boundary(now, hour, weekday, first_run=None):
"""datetime berikutnya utk distribusi.
Mingguan: `weekday` jam `hour`. Bila `first_run` diset, distribusi ⊥ jalan
sebelum tanggal itu — Sabtu (atau hari mingguan) yang jatuh sebelum launch
dilewati; event = {one-off `first_run` (jika masih depan)} ∪ {mingguan ≥
tanggal `first_run`}; pilih yang terdekat > `now`."""
days_ahead = (weekday - now.weekday()) % 7
weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0)
+ timedelta(days=days_ahead))
if weekly <= now:
weekly += timedelta(days=7)
if first_run is None:
return weekly
start = datetime(first_run.year, first_run.month, first_run.day, hour=hour)
candidates = []
if start >= now:
candidates.append(start)
if weekly >= start:
candidates.append(weekly)
return min(candidates)
def _wait_db(timeout=120):
@@ -58,14 +94,18 @@ def _run_distribute(label):
def main():
hour = int(os.getenv('DISTRIBUTE_HOUR', '10'))
hour = config.DISTRIBUTE_HOUR
if not 0 <= hour <= 23:
raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}')
print(f'Jadwal distribusi: jam {hour} (waktu lokal)')
weekday = parse_weekday(config.DISTRIBUTE_WEEKDAY)
first_run = parse_first_run(config.DISTRIBUTE_FIRST_RUN)
jadwal = (f'jam {hour} tiap hari {list(WEEKDAYS)[weekday]} (waktu lokal)'
+ (f', one-off {first_run.isoformat()}' if first_run else ''))
print(f'Jadwal distribusi: {jadwal}')
_wait_db()
while True:
now = datetime.now()
target = next_boundary(now, [hour])
target = next_boundary(now, hour, weekday, first_run)
wait = (target - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {target.isoformat(timespec="seconds")}', flush=True)
+6
View File
@@ -50,6 +50,10 @@ services:
DASH_WORKERS: "2"
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
DASH_LIQUID_TTL: "60"
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
ports:
- published: 5000
target: 5000
@@ -104,6 +108,8 @@ services:
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
+37
View File
@@ -106,6 +106,41 @@ body {
white-space: nowrap;
}
/* ————— news (jadwal distribusi berikutnya, V38) ————— */
.news {
margin: 0 var(--sp-lg) var(--sp-xl);
border: 1px solid var(--hairline-strong);
padding: var(--sp-lg);
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.news-tag {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--link);
}
.news-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 400;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--on-dark);
}
.news-date {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--on-dark);
font-variant-numeric: tabular-nums;
}
.news .caption { margin-top: 0; }
/* ————— stats band ————— */
.stats {
display: grid;
@@ -321,6 +356,8 @@ button.pill {
/* ————— mobile (<768px): list → kartu berlabel ————— */
@media (max-width: 768px) {
.news { margin-left: var(--sp-md); margin-right: var(--sp-md); }
.news-date { font-size: 24px; }
.stats { grid-template-columns: 1fr; }
.spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); }
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=8) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=9) }}">
</head>
<body>
<header class="top-nav">
@@ -54,7 +54,7 @@
<span class="vote-date" data-label="STATUS">{{ 'OK' if run[6] == 'ok' else 'SEBAGIAN' }}</span>
</div>
{% else %}
<div class="empty">BELUM ADA RUN — JALANKAN distribute.py</div>
<div class="empty">BELUM ADA DISTRIBUSI</div>
{% endfor %}
</div>
</section>
+14 -5
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=8) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=9) }}">
</head>
<body>
<header class="top-nav">
@@ -23,7 +23,20 @@
<p class="caption">PEMINDAIAN TERAKHIR · <span class="ts">{{ scanned }}</span></p>
</section>
<section class="news">
<span class="news-tag">BERITA</span>
<h2 class="news-title">DISTRIBUSI BERIKUTNYA</h2>
<p class="news-date">{{ next_schedule }}</p>
{% if schedule_recur %}
<p class="caption">{{ schedule_recur }}</p>
{% endif %}
</section>
<section class="stats">
<div class="spec-cell">
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
<span class="cell-label">SALDO LIQUID</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ total_voters }}</span>
<span class="cell-label">TOTAL PEMILIH</span>
@@ -36,10 +49,6 @@
<span class="cell-value">{{ top_staked|vex }}</span>
<span class="cell-label">STAKE TERTINGGI</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
<span class="cell-label">SALDO LIQUID</span>
</div>
</section>
<form class="search" method="get" action="{{ url_for('index') }}">
+37
View File
@@ -82,6 +82,8 @@ def main():
check('V16 4 sel stats (3 pemilih + saldo liquid)',
html.count('spec-cell') == 4)
check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html)
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
staked_vals = []
@@ -106,6 +108,41 @@ def main():
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
in html)
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
resp = client.get('/')
html = resp.get_data(as_text=True)
check('V38 tag BERITA', 'BERITA' in html)
check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html)
check('V38 tanggal id-ID (Senin 17 Agt)',
'SENIN · 17 AGUSTUS 2026 · 10:00' in html)
check('V38 baris berulang TAK tampil saat first-run (17 Agt ≠ Sabtu)',
'SETIAP SABTU · 10:00' not in html)
check('V38 _is_first_run_next sebelum launch = True',
dashboard._is_first_run_next(datetime(2026, 8, 6)) is True)
check('V38 _is_first_run_next setelah launch = False',
dashboard._is_first_run_next(datetime(2026, 8, 18)) is False)
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0)
html = client.get('/').get_data(as_text=True)
check('V38 baris berulang tampil utk putaran mingguan',
'SETIAP SABTU · 10:00' in html)
# drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor)
import distribute_loop
drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0),
datetime(2026, 8, 22, 9, 0), datetime(2026, 8, 22, 11, 0),
datetime(2026, 8, 15, 9, 0)]
fr = dashboard._parse_first_run('2026-08-17')
check('V38 drift-guard one-off = distribute_loop',
all(dashboard._next_boundary(n, 10, 5, fr)
== distribute_loop.next_boundary(n, 10, 5, fr)
for n in drift_dts))
check('V38 drift-guard mingguan = distribute_loop',
all(dashboard._next_boundary(n, 10, 5)
== distribute_loop.next_boundary(n, 10, 5)
for n in drift_dts))
# halaman melebihi total → dibatasi (tidak error)
resp = client.get('/?page=99')
check('V10 halaman ekstra → 200', resp.status_code == 200)
+101
View File
@@ -0,0 +1,101 @@
"""Oracle jadwal distribusi mingguan (V37): next_boundary + parser.
Murni logika kalender — ⊥ jaringan/DB. Verifikasi: first_run one-off diambil
bila lebih dekat dari mingguan; batas dihitung ulang tiap iterasi loop
(distribute_loop.main loop tak pernah keluar); first_run yang sudah lewat
dibuang → hanya jadwal mingguan.
"""
from datetime import datetime
import distribute_loop as dl
CHECKS = 0
def check(tag, cond):
global CHECKS
CHECKS += 1
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
# parse_weekday: nama + angka, kasus-tak-acak
check('parse_weekday sat', dl.parse_weekday('sat') == 5)
check('parse_weekday SUN upper', dl.parse_weekday('SUN') == 6)
check('parse_weekday mon', dl.parse_weekday('mon') == 0)
check('parse_weekday angka 0-6', dl.parse_weekday('3') == 3)
check('parse_weekday 6', dl.parse_weekday('6') == 6)
for bad in ('', 'xyz', '7', '-1'):
try:
dl.parse_weekday(bad)
raise AssertionError(f'GAGAL: parse_weekday {bad!r} harus raise')
except ValueError:
pass
check('parse_weekday invalid raise', True)
# parse_first_run: kosong → None, format → date
check('parse_first_run kosong', dl.parse_first_run('') is None)
check('parse_first_run None', dl.parse_first_run(None) is None)
fr = dl.parse_first_run('2026-08-17')
check('parse_first_run format', fr.year == 2026 and fr.month == 8
and fr.day == 17)
try:
dl.parse_first_run('17/08/2026')
raise AssertionError('GAGAL: format salah harus raise')
except ValueError:
pass
check('parse_first_run format salah raise', True)
# — next_boundary: mingguan Sabtu jam 10 + one-off Senin 17 Agt —
# Awal (Kamis 6 Agt pagi): Sabtu 8 Agt jatuh SEBELUM launch 17 Agt → dilewati,
# event terdekat = one-off Senin 17 Agt
now = datetime(2026, 8, 6, 9, 0) # Kamis
t = dl.next_boundary(now, 10, 5, fr)
check('Sabtu sebelum launch dilewati → Senin 17 Agt',
t == datetime(2026, 8, 17, 10, 0))
# Sama jam sudah lewat di hari one-off → mingguan Sabtu berikutnya
now = datetime(2026, 8, 17, 11, 0) # Senin, jam lewat
t = dl.next_boundary(now, 10, 5, fr)
check('first_run lewat → Sabtu 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Mingguan saja (⊥ first_run) → Sabtu terdekat dari sekarang
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5)
check('tanpa first_run → Sabtu 8 Agt',
t == datetime(2026, 8, 8, 10, 0))
# Hari yang sama, jam belum lewat → hari ini
t = dl.next_boundary(datetime(2026, 8, 22, 9, 0), 10, 5)
check('Sabtu jam belum lewat → hari ini',
t == datetime(2026, 8, 22, 10, 0))
# Hari yang sama, jam sudah lewat → minggu depan
t = dl.next_boundary(datetime(2026, 8, 22, 11, 0), 10, 5)
check('Sabtu jam lewat → Sabtu pekan depan',
t == datetime(2026, 8, 29, 10, 0))
# first_run = hari Sabtu (22 Agt) → Sabtu2 sebelum launch dilewati
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5,
dl.parse_first_run('2026-08-22'))
check('first_run Sabtu → Sabtu 8/15 Agt dilewati, event = 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Satu hari sebelum launch (Sabtu 15 Agt pagi) → tetap 22 Agt
t = dl.next_boundary(datetime(2026, 8, 15, 9, 0), 10, 5,
dl.parse_first_run('2026-08-22'))
check('sehari sblm launch Sabtu → tetap 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Hari lain dalam seminggu: Rabu (weekday 2) → next Rabu 10:00
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 2)
check('Rabu → Rabu 12 Agt', t == datetime(2026, 8, 12, 10, 0))
print(f'SEMUA UJI PASS ({CHECKS} checks)')
if __name__ == '__main__':
main()