Merge remote-tracking branch 'origin/master' into release-0.3.15
# Conflicts: # .github/workflows/ci.yml # tools/ci/install-hooks.sh # tools/ci/no-foreign-tree-writes.sh # tools/ci/windows-paths-check.sh
101
.github/workflows/ci.yml
vendored
|
|
@ -1,10 +1,19 @@
|
|||
# CI on every push and pull request (private repository, free runner minutes).
|
||||
#
|
||||
# What runs: the lottery-hash crate's tests (igneum-pow, release profile), the census tool's build, the two Python
|
||||
# simulators' --quick modes (each under two minutes), the site build with an internal link check, the gh-free
|
||||
# identity grep of the public export list (tools/ci/forbidden-strings.txt), and the no-secrets check of the tree
|
||||
# (tools/ci/no-secrets-check.sh: no file named like a key of ~/.config/igneum, no 64-hex value assigned to a
|
||||
# token/key/secret name outside tests and the allowlist; docs/security/keys.md).
|
||||
# simulators' --quick modes (each under two minutes), and the tree gate: every fast check in ONE script,
|
||||
# tools/ci/pre-push.sh (site build and link check, the ledger sentences, the identity grep of the public export list
|
||||
# and the served site, Windows-valid paths, workflow shell syntax, the copied-sources and playbook classes, the unit
|
||||
# tests, the no-secrets check). The pre-push hook runs the SAME script before a push to master or release-*, so the
|
||||
# local gate and CI cannot drift (6 October 2026: 131 red `ci` runs in three days, 92 of them on master, every one a
|
||||
# tree check that would have failed on the pushing machine in under 25 s; docs/analysis/ci-failures-2026-10-06.md).
|
||||
#
|
||||
# Where it runs: `pow` and `sims` go to the box's runner (igneum-build-1, rustc pinned, sccache read-only, 48 jobs)
|
||||
# when the repository variable IGNEUM_CI_RUNNER is `box`, else to ubuntu-latest (docs/plans/ci-self-hosted.md; GitHub
|
||||
# has no fallback in runs-on, the variable is the switch). The `site` job stays on GitHub's machines. The `red` job
|
||||
# runs on the box after any failed master or release-* run and records the failure for the watcher
|
||||
# (tools/ci/red-watch.mjs; infra/build-server/ci-red): one line per run to the hidden updates channel and to
|
||||
# /srv/ci-red/red.jsonl, so nobody opens the Actions page to learn master is red.
|
||||
#
|
||||
# What does not run, on purpose: the node fork (vendor/igneum-node*, a rusty-kaspa fork of about 500 crates with
|
||||
# rocksdb, blst and the execution layer) is gitignored here and too big for the free runners today (a cold build is
|
||||
|
|
@ -17,7 +26,7 @@ on:
|
|||
jobs:
|
||||
pow:
|
||||
name: igneum-pow tests, igneum-census build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: toolchain
|
||||
|
|
@ -32,13 +41,15 @@ jobs:
|
|||
run: cargo build --release
|
||||
sims:
|
||||
name: simulators, quick modes
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.IGNEUM_CI_RUNNER == 'box' && fromJSON('["self-hosted", "linux", "x64", "igneum-build-1"]') || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
if: vars.IGNEUM_CI_RUNNER != 'box' # the box has python3 and numpy from provision.sh
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: python3 -m pip install --quiet numpy
|
||||
if: vars.IGNEUM_CI_RUNNER != 'box'
|
||||
- name: finality_v2.py --quick (under two minutes)
|
||||
working-directory: sim
|
||||
run: time timeout 120 python3 finality_v2.py --quick > finality_quick.md
|
||||
|
|
@ -59,58 +70,32 @@ jobs:
|
|||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: site build
|
||||
run: node site/build.mjs
|
||||
- name: internal link check of site/*.html
|
||||
run: node tools/ci/link-check.mjs
|
||||
- name: identity grep of the public export list
|
||||
run: bash tools/ci/identity-check.sh
|
||||
- name: no conflict markers in tracked files
|
||||
run: bash tools/ci/no-conflict-markers.sh
|
||||
- name: every tracked path is valid on Windows (tools/ci/windows-paths-check.sh)
|
||||
run: bash tools/ci/windows-paths-check.sh
|
||||
- name: PowerShell drive-reference check (a `$name:` inside a double-quoted string is a 5.1 parse error)
|
||||
run: bash tools/ci/ps-drive-ref-check.sh
|
||||
- name: copied sources are re-stamped before a build
|
||||
run: bash tools/ci/copied-sources-check.sh
|
||||
- name: override params files parse with no duplicate key (the duplicate-field class, 6 October 2026)
|
||||
run: bash tools/ci/override-json-check.sh
|
||||
- name: second-engine playbooks log to a file and end their tree (C35)
|
||||
run: bash tools/ci/second-engine-check.sh
|
||||
- name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree)
|
||||
run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh
|
||||
- name: no script writes into another worktree or walks Projects (tools/ci/no-foreign-tree-writes.sh)
|
||||
run: bash tools/ci/no-foreign-tree-writes.sh --self-test && bash tools/ci/no-foreign-tree-writes.sh
|
||||
- name: the signer is never piped into head
|
||||
run: bash tools/ci/signer-pipe-check.sh
|
||||
- name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree)
|
||||
run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh
|
||||
- name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree)
|
||||
run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh
|
||||
- name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree)
|
||||
run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs
|
||||
- name: pinned guest programs match their manifest and are built only by pin-guests.sh
|
||||
run: bash tools/ci/pinned-guests-check.sh
|
||||
- name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026)
|
||||
run: bash tools/ci/prover-socket-check.sh
|
||||
- name: commit-string gate self-test (the empty-commit class of 6 October 2026; the gate itself runs in build-remote.sh, cross-remote.sh and cross-build.sh on every node binary)
|
||||
run: bash tools/ci/commit-string-check.sh --self-test
|
||||
- name: build server remote checkout self-test (the stale-overlay class of 6 October 2026)
|
||||
run: bash infra/build-server/remote-run.sh --self-test
|
||||
- name: no shell assignment hides behind a trailing comment (the swallowed-defaults class of 6 October 2026)
|
||||
run: bash tools/ci/defaults-line-check.sh --self-test && bash tools/ci/defaults-line-check.sh
|
||||
- name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree)
|
||||
run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh
|
||||
- name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors)
|
||||
run: node --test site/api/faucet.test.mjs
|
||||
- name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture)
|
||||
run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
- name: the tree gate, tools/ci/pre-push.sh --ci (the same script the pre-push hook runs; one line per check, a red check prints its output)
|
||||
run: bash tools/ci/pre-push.sh --ci
|
||||
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: node tools/ci/public-api-check.mjs https://igneum.network
|
||||
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
|
||||
run: node tools/ship-app.mjs --self-test
|
||||
- name: relay unit tests (parsers, secret compare, the wake endpoint)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
|
||||
red:
|
||||
# Runs only when a master or release-* run has a failed job, on the box's own runner (not a GitHub-hosted machine:
|
||||
# the billing block of 6 October 2026, 18:37Z to 20:10Z, failed every hosted job at start and nobody was told).
|
||||
# tools/ci/red-watch.mjs record appends ONE line for this run to /srv/ci-red/red.jsonl (idempotent per run attempt);
|
||||
# the box's igneum-ci-red.timer posts each new line once to the hidden updates channel. Never blocks a release:
|
||||
# it reads the run, writes one line, and ends.
|
||||
name: red watcher (master and release-* only; one line per failed run to the updates channel and the box file)
|
||||
needs: [pow, sims, site]
|
||||
if: ${{ failure() && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) }}
|
||||
runs-on: [self-hosted, linux, x64, igneum-build-1]
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read # the run's jobs API (the first real red run, 21:19Z: the default token answered 403 and the line carried no step)
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci
|
||||
- name: record this run (one line, the failed jobs and their first failed step, from the run's own API)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RED_WATCH_TITLE: ${{ github.event.head_commit.message }}
|
||||
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl
|
||||
|
|
|
|||
21
.github/workflows/windows.yml
vendored
|
|
@ -293,3 +293,24 @@ jobs:
|
|||
path: build/inputs-artifact/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
red:
|
||||
# The red watcher for the Windows pipeline (see ci.yml `red`): one line per failed master or release-* run to the
|
||||
# hidden updates channel and /srv/ci-red/red.jsonl on the box, recorded by the box's own runner.
|
||||
name: red watcher (master and release-* only; one line per failed run to the updates channel and the box file)
|
||||
needs: [parse, build]
|
||||
if: ${{ failure() && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) }}
|
||||
runs-on: [self-hosted, linux, x64, igneum-build-1]
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read # the run's jobs API (the first real red run, 21:19Z: the default token answered 403 and the line carried no step)
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: tools/ci
|
||||
- name: record this run (one line, the failed jobs and their first failed step, from the run's own API)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RED_WATCH_TITLE: ${{ github.event.head_commit.message }}
|
||||
run: node tools/ci/red-watch.mjs record --file /srv/ci-red/red.jsonl
|
||||
|
|
|
|||
112
docs/analysis/block-rate-devnet2.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Block rate on Devnet 2: 10 blocks per second against 1, on the rented fleet
|
||||
|
||||
6 October 2026, the project lead's experiment (through the coordinator, 17:5xZ): "Devnet 2 at a higher block rate for solo miners
|
||||
(Kaspa's answer)". Branch `gpu-fleet`; the node profile on the fork branch `devnet2-bps` (1279a1d6 on release-0.3.14-node
|
||||
4c6b129d): a suffixed devnet started with `IGNEUMD_DEVNET_BPS=10` (or 5) runs `BlockrateParams::new::<10>()` with the
|
||||
TenBps subsidy and target time, Kaspa's Crescendo-style constants for that rate (k 124, merge depth, sample rates,
|
||||
mergeset limit, parents, coinbase maturity from `consensus/core/src/config/bps.rs`); the shared devnet never reads the
|
||||
variable, so the live digest and rules are untouched. Built on igneum-build-1 (`tools/build-remote.sh`). Numbers land
|
||||
below as they are measured; every figure carries its source file under `~/Desktop/fleet/bps/`.
|
||||
|
||||
## The runs
|
||||
|
||||
| Run | Chain | Miners | Length | What is read |
|
||||
|---|---|---|---|---|
|
||||
| A | igneum-devnet-2, fresh genesis, 10 bps | the 38 wave pods plus the 4 Devnet 2 boxes (42 cards, about 2.0 GH/s) | 60 min | blocks per second achieved, blue and red blocks per minute (orphan rate), blue score growth, max reorg depth, checkpoint lock delay and weight at this voter count, p2p bytes per node per minute, node CPU and RSS, exec lag (height minus executed tip), payout intervals per miner from the coinbase records |
|
||||
| B | the same boxes, fresh genesis, 1 bps | the same | 30 min | the same (the control) |
|
||||
| A2 | three relays, 10 bps | the same | dropped: the network lane's read of run A (reds from node throughput, not topology) made a topology run uninformative without per-block CPU profiling on a pod; no provider gave inbound ports for a mesh | |
|
||||
|
||||
Payout interval per tier, from each run's measured block rate and the chain's hash: a 4070 at 28 MH/s, a 5090 at
|
||||
128 MH/s, an 8x 4090 rig at 459 MH/s, at tonight's network hash and extrapolated to 1, 10 and 100 TH/s networks
|
||||
(interval = blocks per second x miner share, the arithmetic in `tools/fleet/bps-collect.py`).
|
||||
|
||||
## Measured
|
||||
|
||||
### Run A: 10 blocks per second, star topology (19:17Z to 20:25Z)
|
||||
|
||||
Seed: the fork's igneumd (83702a35, `IGNEUMD_DEVNET_BPS=10`) on igneum-build-1 (188.40.146.49:26611, a public port), genesis
|
||||
f682b78a4e64f0d2, digest 436d62a5b962e1c2, mining from 19:17:26Z. Miners: dn2-1/2/3 from 19:25Z, the 38 wave pods from 19:26Z
|
||||
(each node beside the pod's live-devnet node on other ports, each dialling the seed only: no pod has an inbound port), 41 peers
|
||||
on the seed. Collector rows every minute in `~/Desktop/fleet/bps/A.jsonl` (the first 20 minutes carry the seed's log counts only;
|
||||
the watch line from 19:37Z after the seed's miner binary was staged).
|
||||
|
||||
| Read | Value | Source |
|
||||
|---|---|---|
|
||||
| Blocks at 10 min (19:36Z) | 5,733 (12.4 blocks/s over the last 159 s) | seed watch line |
|
||||
| Blue score at 10 min | 1,307 (77 percent of blocks red) | the same |
|
||||
| Blocks 19:37Z to 20:24Z (47 min) | 7,204 to 19,153: 4.25 blocks/s; blue +2,926: 1.09 blue/s; red share 77.6 percent | A.jsonl |
|
||||
| Rate by six-minute interval | 7.7, 1.9, 2.9, 2.8, 5.9, 4.2, 3.2, 5.1 blocks/s; blue 0.6 to 1.6/s | A.jsonl |
|
||||
| Tips | 246 at 10 min, 295 to 662 through the hour, rising | A.jsonl |
|
||||
| Difficulty | 6,719 at 19:33Z, 3,551 at 19:36Z, 1,307 at 19:42Z, falling the whole hour (the rule reads the blue rate under target and eases) | seed watch, by hand |
|
||||
| Max selected-chain reorg | 55 blocks (a late pod unwinding its genesis-only view at join) | seed log |
|
||||
| Exec follower | tip 84 at 19:30Z, 240 at 20:19Z: 0.05 blocks/s against 1.1 blue/s; lag 18,901 blocks at the end | igneum_getExecStatus on the seed |
|
||||
| Finality | 18 locks over the run (the voters are the 42 miners' keys) | seed log |
|
||||
|
||||
What it says: with 42 cards on a 10 blocks/s profile the DAG ran at 4 to 12 blocks a second but the selected chain at about
|
||||
1.1 blue blocks a second, three in four blocks red, tips in the hundreds, and the difficulty rule eased all hour because it
|
||||
measures the blue rate. The Horizon network lane's read of the same rows (`docs/analysis/horizon/network.md`): the reds came
|
||||
from node throughput, not the star: the seed spent 61 to 345 ms of CPU per accepted block (mergeset 8 to 200), a 100 ms-per-block
|
||||
knee at 10 blocks/s, RSS 5.4 GB at 12,000 blocks, and the propagation model gives 0.0 percent red for both star and mesh at
|
||||
the measured latencies. So a topology run (A2) proves nothing without per-block CPU profiling on a pod, and was dropped. A
|
||||
true mesh was also not possible tonight: no provider gave a pod with an inbound p2p port, the live devnet has the same star
|
||||
(every rented node dials the two hands and the hub), and the standing-fleet ports rule in `docs/plans/gpu-fleet.md` is the fix.
|
||||
|
||||
The exec follower is the second finding: at 1.1 blue blocks a second it executed 0.05 blocks a second, so a 10 blocks/s chain
|
||||
with payload would leave every wallet and prover reading state hours behind the tip within the first hour.
|
||||
|
||||
### Run B: 1 block per second, the control, same boxes (20:25Z to 20:58Z)
|
||||
|
||||
Seed restarted on a fresh genesis without the profile variable (1 block/s, the devnet's rule), the same 42 boxes, each wiped
|
||||
and rejoined (`FRESH=1`), rows in `~/Desktop/fleet/bps/B.jsonl` from 20:28Z.
|
||||
|
||||
| Read | Value | Source |
|
||||
|---|---|---|
|
||||
| Blocks 20:28Z to 20:58Z (29.5 min) | 2,679 to 5,171: 1.41 blocks/s; blue +2,099: 1.19 blue/s; red share 15.8 percent over the window | B.jsonl |
|
||||
| The first six minutes | 3.05 blocks/s against 1.93 blue/s: the join burst (42 nodes arriving on a chain minutes old, the late ones unwinding genesis-only views; max reorg 16) | B.jsonl |
|
||||
| From 20:34Z on, by six-minute interval | 1.01, 0.99, 0.94, 1.05 blocks/s and 1.01, 0.99, 0.95, 1.05 blue/s: red share under 2 percent | B.jsonl |
|
||||
| Tips | 21 at 20:28Z, 1 to 3 from 20:34Z | B.jsonl |
|
||||
| Difficulty | 19.9 M at 20:28Z, 477 M by 20:34Z, 453 to 482 M after: settled in six minutes and flat | B.jsonl |
|
||||
| Max selected-chain reorg | 16 (the join) | seed log |
|
||||
| Exec follower | tip 188 at 20:28Z, 1,003 at 20:58Z: 0.46 blocks/s against 1.0 blue/s, lag 4,168 at the end and growing | igneum_getExecStatus |
|
||||
| Finality | 0 locks in 30 minutes (the chain was 30 minutes old; the window had not filled) | seed log |
|
||||
|
||||
What it says: the same 42 cards that made a 77 percent red DAG at the 10 blocks/s profile made a chain with one to three tips
|
||||
and under 2 percent red at 1 block/s, with the difficulty settled in six minutes. The exec follower still ran under the chain
|
||||
(0.46 against 1.0), which is the follower's own ceiling on these pods and a finding for the proving lane, not for the rate.
|
||||
|
||||
## Per tier
|
||||
|
||||
From the collector's arithmetic (interval = 1 / (blocks per second x miner hash / network hash)); run A's "blocks" are
|
||||
DAG blocks of which three in four were red, so its payout column is read at a quarter.
|
||||
|
||||
| Network hash | Miner | Run A (4.87 DAG blocks/s, 1.09 blue/s) | Run B (1.19 blue blocks/s) |
|
||||
|---|---|---|---|
|
||||
| tonight, 2.0 GH/s | 4070, 28 MH/s | a block every 0.2 min, of which 1 in 4 pays | a block every 0.8 min, nearly all pay |
|
||||
| tonight, 2.0 GH/s | 5090, 128 MH/s | every 0.1 min | every 0.2 min |
|
||||
| tonight, 2.0 GH/s | 8x 4090 rig, 459 MH/s | continuous | every 0.1 min |
|
||||
| 1 TH/s | 4070 | every 2.0 h (DAG), about 8 h in blue blocks | every 7.0 h |
|
||||
| 1 TH/s | 5090 | every 27 min (DAG), about 1.8 h blue | every 1.5 h |
|
||||
| 1 TH/s | 8x 4090 rig | every 7.4 min (DAG), about 30 min blue | every 26 min |
|
||||
| 10 TH/s | 4070 | every 20 h (DAG), about 3.4 days blue | every 2.9 days |
|
||||
| 10 TH/s | 5090 | every 4.5 h (DAG), about 18 h blue | every 15 h |
|
||||
| 10 TH/s | 8x 4090 rig | every 1.2 h (DAG), about 5 h blue | every 4.3 h |
|
||||
| 100 TH/s | 4070 | every 8.5 days (DAG), about a month blue | every 29 days |
|
||||
| 100 TH/s | 5090 | every 1.9 days (DAG), about a week blue | every 6.4 days |
|
||||
| 100 TH/s | 8x 4090 rig | every 12 h (DAG), about 2 days blue | every 1.8 days |
|
||||
|
||||
Consequence per tier: a home 4070 at a 10 TH/s network waits about three days for a paying block either way (the 10 blocks/s
|
||||
profile's extra DAG blocks are red, so the solo miner's variance does not fall by 10x, only by the blue-rate ratio of 1.09 to
|
||||
1.19, which is nothing); the rig and the 5090 see the same. The way to a shorter wait for the small card is the pool, not
|
||||
the block rate. GHOSTDAG pays red blocks nothing under this rule set, so a higher rate is only worth having where the blue
|
||||
rate rises with it, which needs the node to process a block in well under 100 ms at mergeset 248.
|
||||
|
||||
## The recommendation for mainnet's rate
|
||||
|
||||
From the Horizon network lane (`docs/analysis/horizon/network.md`), carried here as the experiment's recommendation: 1 block
|
||||
per second for the public testnet and the launch; 10 blocks per second behind three gates, each measured before the rate moves:
|
||||
(1) per-block node CPU under 50 ms at mergeset 248 on a laptop core (tonight's seed: 61 to 345 ms, a knee at 100 ms per block
|
||||
at 10 blocks/s, RSS 5.4 GB at 12,000 blocks); (2) finality constants and the clock cap expressed in DAA seconds, so a rate
|
||||
change moves no human-time guarantee; (3) vote aggregation, so 100 voters at 10 blocks/s do not multiply the certificate
|
||||
traffic by ten. Two fleet rules from the runs: a box never mines from a genesis-only view, it syncs first (the 55-block reorg
|
||||
of run A and the 16-block one of run B were late pods unwinding); and the exec follower's rate (0.05 and 0.46 blocks/s on
|
||||
these pods) is the state layer's ceiling and must be measured beside any block-rate change.
|
||||
160
docs/analysis/ci-failures-2026-10-06.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# CI failures, 4 to 6 October 2026: every non-green run classified, the fixes, the guards
|
||||
|
||||
Written 6 October 2026, 22:0x UK, from `gh run list` (430 runs, every run since the first workflow run at 09:57Z on
|
||||
4 October) and `gh run view --log-failed` on one run per class. Times UTC (UK was UTC+1). This document is an operations
|
||||
record and is listed in `tools/ci/export-exclude.txt`: it is not exported to the public mirror.
|
||||
|
||||
## 1. The totals
|
||||
|
||||
| Workflow | Runs | Green | Failed | Cancelled |
|
||||
|---|---:|---:|---:|---:|
|
||||
| ci | 336 | 205 | 131 | 0 |
|
||||
| windows-ci | 94 | 57 | 20 | 17 |
|
||||
| total | 430 | 262 | 151 | 17 |
|
||||
|
||||
126 of the 168 non-green runs were on master. Master was red without a break from 18:37Z to the end of the evening
|
||||
(20:23Z, run 37526027569) across three causes in a row: the billing block, the copied-sources check, the identity grep.
|
||||
|
||||
## 2. Every failure by root cause
|
||||
|
||||
One line per class. "Guard" is what now stops the class before it reaches master.
|
||||
|
||||
| Class | Runs | First | Last | Cause | Fix | Guard |
|
||||
|---|---:|---|---|---|---|---|
|
||||
| A1 identity grep | 56 | 04 13:26Z | 05 01:46Z | A simulator's run log committed under `sim/difficulty/records` carried the Mac's home path in its first line; 43 master pushes in twelve hours each failed the same step | The log was scrubbed; `.log` files joined the generic scrub (mirror e18256d) | The pre-push gate runs the identity grep locally before any push to master or release-* (`tools/ci/pre-push.sh --hook`), so the hit lands on the pushing machine, not on master |
|
||||
| A2 identity grep | 17 | 05 02:17Z | 05 10:36Z | vmmap dumps under `docs/benchmarks/memory-floods-2026-10-04/vmmap/` carried a local time offset on their Date/Time lines | The dumps were re-stamped to UTC | Same gate |
|
||||
| A3 identity grep | 1 | 06 20:23Z | 06 20:23Z | `docs/analysis/horizon/polish.md`, a research review of internal tooling, quotes the overlay network's product name, the intake key's variable name and the identity guard's own regexes as text; docs/analysis is in the export list, so the grep is right to flag it | The document is listed in `tools/ci/export-exclude.txt`; the identity check and the mirror's `sync.sh` both prune that list, so the guard's purpose (nothing the public reads carries these strings) is intact and the research text is untouched | The exclusion list, plus the gate; `identity-check.sh --self-test` shows an excluded path may quote the patterns and an exported one may not |
|
||||
| B1 hosted runner refused | 32 | 06 18:37Z | 06 20:05Z | "The job was not started because recent account payments have failed or your spending limit needs to be increased": every GitHub-hosted job of every run failed at start with zero steps, 26 master runs and 6 release-0.3.15 runs, and nothing told anyone | Cleared on the billing page by 20:08Z | The `red` job runs on the box's own runner after any failed master or release-* run and posts one line per run (section 4); the `pow` and `sims` jobs can move to the box with one repository variable (section 5) |
|
||||
| C1 copied-sources | 1 | 06 18:00Z | 06 18:00Z | `tools/workers/collect.mjs` mentioned rsync and cargo in a comment; the check read comments | The check skips comment lines | The gate |
|
||||
| C2 copied-sources | 12 | 06 20:08Z | 06 20:19Z | `infra/build-server/repro/rebuild-on-box.sh` clones sources and runs cargo without `touch`; 10 master runs and 2 release-0.3.15 runs | 0f0abc6 (box-work 2bd3bec): the repro script re-stamps its clones. Release-0.3.15 still carries the old script at c25a3ca and will fail this step again until it takes master (or cherry-picks 2bd3bec) | The gate |
|
||||
| D no-foreign-tree-writes | 2 | 06 18:20Z | 06 18:24Z | The new check's warning pipeline (`grep | grep -v | sed | cut`) fails under `pipefail` on a file with no hit, and `set -e` ends the script silently with exit 1 after "self-test passed"; the two runs right after it landed died this way, and the Mac's bash 3.2 died the same way on every tree | `|| true` on the warning pipeline (this change) | The gate runs the check locally, where it would have shown |
|
||||
| E Windows checkout | 3 | 04 13:53Z | 06 20:15Z | Nine screenshot files under `docs/plans/site-ui-3-shots/` carried a colon from an address; git on windows-latest refuses the path, so `actions/checkout` died and with it every Windows build of the tree (the 0.3.15 installer waited on it) | 61f46cc renamed the nine files | `tools/ci/windows-paths-check.sh`: colon and the other forbidden characters, trailing dot or space, reserved device names, over 240 characters; as the pre-commit hook on the staged paths and in the gate on every tracked path |
|
||||
| F1 site build | 5 | 04 13:46Z | 04 13:53Z | `site/scrub-bench.sh` failed on `docs/bench-log.md` and `build.mjs` threw from the execSync | Fixed in the bench log the same afternoon | The gate builds the site in a temporary copy before the push |
|
||||
| F2 site build | 2 | 05 16:42Z | 05 16:43Z | Conflict markers left in `site/journey.json`; `build.mjs` parsed it as JSON | Resolved by hand; `no-conflict-markers.sh` and the first pre-push hook (fb076de) followed | The gate's first check, on every push |
|
||||
| G link check | 1 | 05 09:28Z | 05 09:28Z | `/#wallet` linked from every page with no such id (release-0.3.6) | Anchor added | The gate |
|
||||
| H windows payload inputs | 4 | 05 16:30Z | 06 18:15Z | The signed inputs manifest on the downloads host pinned one node commit and `packaging/windows/node-source.pin` in the tree another: the Mac had pushed new inputs without committing the pin, or committed a pin without pushing inputs | Each time, the pin and the inputs were brought level | Not a tree check and not in the gate: the shipper's push-inputs.sh writes the pin and the commit must carry it; the `red` job now reports the mismatch within a minute instead of the next person opening the Actions page |
|
||||
| I windows installer | 1 | 04 10:32Z | 04 10:32Z | The runner image's Inno Setup was older than 6.3 | The workflow installs Inno Setup when the image's is too old | Resolved in the workflow |
|
||||
| J windows engine | 2 | 04 13:53Z | 04 13:56Z | Rust that did not compile pushed to master (`expected identifier, found keyword let`) | Fixed in the next push | A compile is not a 25-second check; the owner's merge rule (CLAUDE.md, "CI red is stop-the-line") covers it: the merger fixes or reverts inside 15 minutes |
|
||||
| K igneum-census | 1 | 05 23:18Z | 05 23:18Z | igneum-pow's `Instr`, `Program` and a layout argument changed; igneum-census was not rebuilt (release-0.3.11) | Updated with the crate | As J |
|
||||
| L prover-socket | 1 | 06 08:49Z | 06 08:49Z | `tools/proving-v1/pc2-agg-cost.ps1` ran the prover host as root without killing sp1-gpu-server (release-0.3.12) | The playbook was fixed | The gate |
|
||||
| M public API check | 1 | 06 15:12Z | 06 15:12Z | The live observer was 969 s stale when the master-only live check ran | The observer recovered; the hands moved to the box that evening | A live check stays in CI only, master only; it is not a tree fact and not in the gate |
|
||||
| N no-secrets | 2 | 06 15:56Z | 06 16:23Z | A 64-hex test vector next to `private_key` in `app/igneum-wallet/src/vault.rs` (wallet-0.1.5) | Allow-listed as a test value | The gate |
|
||||
| P swallowed defaults line (no CI run: a silent class) | 0 | 06 19:5xZ | 06 21:xxZ | A comment appended to a line of shell assignments in `tools/build-remote.sh` and then `tools/cross-remote.sh` turned every assignment after the `#` into comment text; `bash -n` and shellcheck are silent on it; the default cross-build never ran and its chain kept the previous exes from about 20:40 to 22:00 UK | 36e4ee7 on master: the lines split; `tools/ci/defaults-line-check.sh` with its self-test | In ci.yml at 36e4ee7 and in the gate from this change, so it runs on the pushing machine before the push |
|
||||
| Q gate checks that read the machine, not the fact (found by the gate itself, 22:1x to 22:3x UK) | 0 | 06 21:1xZ | 06 21:3xZ | Two pushes of this change to master were refused by the new hook: (1) git hands a hook `GIT_DIR`, and `remote-run.sh --self-test`'s nested `git init`, commits and reset then acted on THIS repository's worktree: it set `core.bare`, moved the local `master` to three fixture commits and broke the main checkout for ten minutes (restored from the reflog: master back to 36e4ee7, `core.bare false`; nothing was pushed, nothing lost); (2) the same self-test judged a stale `index.lock` by `pgrep -x git` over the whole machine, so the hook's own `git push` (or any other agent's git) made the fixture's checkout die with "index.lock: File exists" | The gate unsets `GIT_DIR` and the other hook variables before any check; the staleness test is the lock's age (over 30 s), and the self-test backdates its fixture lock | The gate itself: every self-test now runs inside a real hook before every master push, with a `git push` alive beside it |
|
||||
| O1 windows-ci cancelled | 16 | 04 10:42Z | 06 18:12Z | `concurrency: cancel-in-progress` on windows.yml: a newer master push superseded the run. Not a failure | None needed | None; they are listed because `gh run list` counts them as non-green |
|
||||
| O2 hosted runner not acquired | 8 | 05 19:26Z | 05 20:54Z | "The job was not acquired by Runner of type hosted even after multiple attempts" on release-0.3.10 (7) and master (1): GitHub capacity, retried by hand | Re-run | The `red` job reports it; the box runner for `pow` and `sims` (section 5) takes those jobs off the hosted pool |
|
||||
|
||||
Sum: 168 runs, plus class P, which never reached CI because nothing checked for it. Classes A1 to A3, C1, C2, D, E,
|
||||
F1, F2, G, L and N are 102 runs (61 percent), every one a tree check that finishes in under 25 s on the pushing machine. B1 and O2 are 40 runs (24 percent) of GitHub-side refusals that nobody
|
||||
saw until the Actions page was opened. O1 is 16 runs (10 percent) of expected cancellations. H, I, J, K and M are the
|
||||
remaining 10.
|
||||
|
||||
## 3. The gate: one script, local and CI (`tools/ci/pre-push.sh`)
|
||||
|
||||
Every fast tree check CI runs is in one script. The `site` job of ci.yml calls `tools/ci/pre-push.sh --ci`; the pre-push
|
||||
hook calls `tools/ci/pre-push.sh --hook`. The two cannot drift because there is one list. A check added to ci.yml alone
|
||||
is the wrong place; it goes in the script.
|
||||
|
||||
| Mode | When | What |
|
||||
|---|---|---|
|
||||
| `--hook` on a push to master or release-* | installed by `tools/ci/install-hooks.sh` into the shared hooks directory (one set for every worktree) | all 31 checks; a red check refuses the push and prints its output |
|
||||
| `--hook` on any other ref | same | the two structural checks only (conflict markers, Windows paths) |
|
||||
| `--ci` | the `site` job | all 31 checks, with the site built in place |
|
||||
| default | by hand in any worktree | all 31 checks |
|
||||
| `--self-test` | in the gate itself | a known failure is RED and fails the gate; a known success is ok; master and release-* select the full gate, other refs the light one |
|
||||
|
||||
Measured 6 October 2026, 21:5x UK, on the Mac: 30 checks, GREEN, 25 s (no-secrets 11 s, identity grep 3 s, the rest
|
||||
under 2 s each). The hook never writes into the worktree: the site is built in a temporary copy with
|
||||
`SITE_DOWNLOADS_OFFLINE=1` (063bbca: the earlier hook built in place and rewrote the downloads snapshot in five
|
||||
worktrees); `git status` before and after the full gate is identical.
|
||||
|
||||
Checks that joined CI through the gate and were not in ci.yml before: the ledger sentence check
|
||||
(`ledger-text-check.mjs`), the workflow shell parse (`check-workflow-shell.mjs`), the Windows paths check, and the three
|
||||
self-tests (identity, Windows paths, the red watcher). The swallowed-defaults check (36e4ee7) is in the gate too.
|
||||
|
||||
## 4. The red watcher (`tools/ci/red-watch.mjs`, `infra/build-server/ci-red/`)
|
||||
|
||||
A `red` job in ci.yml and windows.yml runs only when a master or release-* run has a failed job. It runs on the box's
|
||||
own runner (`igneum-build-1`), not on a GitHub-hosted machine, because the hosted pool is the thing that was refused in
|
||||
B1 and O2. It appends one JSON line for the run (id, workflow, branch, commit, title, the failed jobs and each one's first
|
||||
failed step from the run's own API, the URL) to `/srv/ci-red/red.jsonl`, idempotent per run attempt. On the box,
|
||||
`igneum-ci-red.timer` runs the poster every minute as `build`: each line not yet posted goes once to the hidden updates
|
||||
channel through `DISCORD_WEBHOOK_UPDATES` in `/srv/discord-hooks/env`, then its run id is recorded in
|
||||
`/srv/discord-hooks/ci-red-posted.json`. The orchestrator reads the file (`ssh build@<box> cat /srv/ci-red/red.jsonl`)
|
||||
or the channel. No URL is ever printed; a missing key is logged by name.
|
||||
|
||||
Shown on 6 October 2026: the self-test (one line however often `record` runs; the dry run sends nothing; a missing key
|
||||
is named, never a URL; one live send per run; a webhook error keeps the run pending). The poster is installed and
|
||||
active on the box (22:55 CEST, "nothing to post (0 recorded)"). Open: the updates channel has no webhook yet, so the
|
||||
first real red run will land in `red.jsonl` and the poster will log the missing key until `DISCORD_WEBHOOK_UPDATES` is
|
||||
added to `~/.config/igneum/discord` on the Mac and `infra/build-server/discord-hooks/install.sh` is re-run. The
|
||||
Actions-side trigger has not fired on a real red run yet (master was made green in the same change); the first red
|
||||
master or release-* run is its known-failed case.
|
||||
|
||||
## 5. Where CI runs, and why `ci` takes about three minutes
|
||||
|
||||
| Job | Where today | Time on ubuntu-latest | Time on the box (measured 6 October) | Note |
|
||||
|---|---|---|---|---|
|
||||
| pow (igneum-pow `cargo test --release`, packfile test, igneum-census build) | ubuntu-latest | 2 min 30 s to 3 min, cold every run (no cache action) | 42 s cold as the runner user (99 tests), sccache read-only hits after the first build | the long pole |
|
||||
| sims (two Python simulators, --quick) | ubuntu-latest | about 1 min with setup-python and pip | python3 and numpy are on the box from provision.sh | |
|
||||
| site (the gate) | ubuntu-latest | under 1 min | not moved: the live public API check belongs on a neutral egress | |
|
||||
| red | the box's runner | | seconds | only after a failed master or release-* run |
|
||||
|
||||
The workflow now reads the repository variable `IGNEUM_CI_RUNNER`: `box` sends `pow` and `sims` to
|
||||
`[self-hosted, linux, x64, igneum-build-1]`, anything else keeps `ubuntu-latest` (docs/plans/ci-self-hosted.md: GitHub
|
||||
has no fallback in `runs-on`, so a variable is the switch; `gh variable set IGNEUM_CI_RUNNER --body box` as igneum-labs,
|
||||
`gh variable delete IGNEUM_CI_RUNNER` to come back). Recommendation: flip it. The two compile-or-compute jobs are what
|
||||
GitHub's minutes and the billing block were spent on, the box compiles the agents' own pinned rustc 1.99.0, and a `ci`
|
||||
run drops from about three minutes to about one. The hosted runner then serves only the gate and the Windows
|
||||
pipeline (MSVC, WebView2, Inno Setup, PowerShell 5.1, which a Linux box cannot provide). The flip is main's call after the
|
||||
0.3.15 cut, per build-server.md section 7.1.
|
||||
|
||||
## 6. The build box's own red rows (the project lead, 22:3x UK: "also make sure we are fixing and learning from all the errors here")
|
||||
|
||||
Source: `/srv/builds/_log/builds.jsonl` on igneum-build-1, 139 rows from the first build at 18:38 UK to 22:11 UK on
|
||||
6 October; 34 with a non-zero exit. Until this change the box kept no output of a run (it streamed to the agent's terminal),
|
||||
so a red row could not be read afterwards; the classes below are from exit code, duration, compile count and what the next
|
||||
row of the same worktree did. Times UK. "Iteration" = the same worktree and kind green inside ten minutes.
|
||||
|
||||
| Time | Worktree, kind | Exit, secs, compiles | What it was | Kind | Would a local pre-check have saved the round trip | Guard now |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 18:49:12 | build-server, suite `--lib jobbuild` on app/igneum-app | 101, 0 s, 0 | Instant death: `--lib` on a crate whose tests live in the bin (the next row, `--bin`, was green the same minute) | real class: instant | Yes, one second | pre-flight and the kept run log; class `instant` on the row and in the red-run file |
|
||||
| 19:22:14, 19:51:09 | ship0314 then ship0315, prove `--features igneum-prove-host/cuda` | 101, 0 s, 0 (twice) | Instant death, the same command twice on two branches: the feature name (nothing on the box kept the message) | real class: failed the same way twice, instant | Yes | pre-flight refuses a feature the package does not have (`preflight-feature`, exit 3, before the slot) |
|
||||
| 19:26:01 | master, check `-p igneum-prove-core` | 101, 0 s, 0 | Instant death: the package name (no such member) | real class: instant | Yes | pre-flight refuses a `-p` that names nothing (`preflight-package`) |
|
||||
| 19:27:26, 19:27:54 | master, prove | 101, 0 s, 0; then 101, 55 s, 605 | An instant death, then a compile error; green 4 min later | iteration (with one instant) | The instant one, yes | as above; `compile-error` class with the run log kept |
|
||||
| 19:35:56, 19:42:36, 19:48:37, 19:58:12, 21:02:56, 21:47:14 | ship0315, suite and node-linux | 101, 19 to 66 s, 44 to 724 compiles | The shipper's 0.3.15 node fixes: compile and test failures, each green within 1 to 8 min | iteration | No: a Mac check takes 12 to 18 min, the box IS the pre-check | run log kept; class `compile-error` or `test-failure` |
|
||||
| 19:51:09 | ship0315, suite | 2, no duration | `no-dir`: the crate directory was not there, because a second run of the same worktree started in the same second and its checkout was replacing the tree | real class: a shared resource (one worktree directory, two runs) | n/a | one run per worktree directory at a time: `remote-run.sh` takes a per-worktree lock in checkout and run mode and waits (shown: the second of two concurrent runs waited and both finished) |
|
||||
| 19:50:57, 19:52:50 | ca3-v4-node, suite | 101, 12 s and 7 s | Test failures while fixing; green 2 min and 0 min later | iteration | No | run log kept |
|
||||
| 20:45:55 | box-work, night battery dry run | 1, 234 s | The night battery's dry-run subset failed in the box-work agent's hands; its output was on that agent's side | one-off, unread | n/a | the run log is kept from now on; the night battery writes its own report |
|
||||
| 21:01:12 | build-server, `self-test-repro` | 1, 0 s | The repro self-test's first version; green 1 min later | iteration | n/a | none needed |
|
||||
| 21:17:57 | ca3-v4-node, `cargo audit` | 2, 0 s, 0 | Instant death: cargo-audit is not installed on the box | real class: a missing tool, instant | Yes | pre-flight refuses a cargo subcommand the box does not have (`preflight-subcommand`); the install belongs in provision.sh (open: add cargo-audit there, the night battery runs it) |
|
||||
| 21:17:58 to 21:26:42 | finality-pause, two suites alternating | 101, 4 to 26 s | Six reds, the same two commands three times each while the finality pause tests were being fixed; green 2 to 11 min after each | iteration (the first pair outside ten minutes) | No | run log kept; the digest counts the class |
|
||||
| 21:45:13, 21:46:06 | box-capacity, node-linux p2p-probe | 101, 39 s and 3 s | Compile errors; green 1 min and 0 min later | iteration | No | run log kept |
|
||||
| 21:58:26 | ca3-v4-node, check | 101, 12 s | Compile error; green 1 min later | iteration | No | run log kept |
|
||||
| 22:00:08, 22:06:11, 22:08:16 | ca3-v4-node, suite (`finality`, then `clock_rule_v3 a_pause_carries ...`) | 101, 24, 13, 12 s, 22 compiles | The clock rule v3 tests failing while being fixed; green at 22:10 | iteration | No | run log kept |
|
||||
| 22:08:52, 22:08:55, 22:08:57 | ca3-v4-node, three suites in five seconds | 101, 0 s, 0 (three times) | Three instant deaths in a row with nothing compiled: cargo refused before building (a manifest or lock the overlay carried mid-edit, or a name); the three commands were green one minute later, so the tree was fixed under them | real class: instant, failed the same way three times | Yes, one second each | pre-flight (manifest, package, feature, subcommand) before the slot; class `instant` flagged; the run log kept so the next one is read, not guessed |
|
||||
| 22:11:38 | ca3-v4-node, check `--tests` | 101, 49 s, 844 | A compile error in the test targets; still red when the log was read | iteration, open | No | run log kept |
|
||||
|
||||
Sum: 34 red rows. 22 are iteration (an agent taking a red to green inside ten minutes, the box doing the compile the Mac
|
||||
cannot do in time). 12 are real classes, in three families: instant deaths (8 rows: a feature, a package, a target, a
|
||||
tool, and three unread), a shared worktree directory (1 row), and the night battery's unread dry run (1 row, plus two
|
||||
instant ones inside iterations). Every real class now has a guard in `infra/build-server/remote-run.sh`, shown on the box
|
||||
in a sandbox (a pass, a failing test, an empty filter, a missing package, a missing feature, a missing subcommand, a compile
|
||||
error, a broken manifest, two concurrent runs of one worktree):
|
||||
|
||||
| Guard | What it does | Class it stops |
|
||||
|---|---|---|
|
||||
| pre-flight | before the slot's time is spent: `cargo <sub>` exists, the manifest parses (`cargo metadata --no-deps`), every `-p` is a member or a dependency, every `--features pkg/feat` exists; a refusal is exit 3 in about a second with the reason | instant (manifest, package, feature, tool) |
|
||||
| kept output | the last 400 lines of every run in `/srv/builds/_log/runs/<id>.log`, named in the row (`run_log`) | every unread class |
|
||||
| class on the row | `class` in builds.jsonl: compile-error, link-error, test-failure, instant, slot-timeout, no-dir, no-test-matched, preflight-*, other | the digest can count what kind of red it was |
|
||||
| no-test-matched | a `cargo test` with a filter that ran 0 tests in every binary exits 3 instead of a green "0 tests" | a wasted round trip that read as ok |
|
||||
| one run per worktree | a per-worktree lock in checkout and run mode; the second waits up to 2 h and says so | no-dir, the half-replaced tree |
|
||||
| the red-run file | every red row is appended to `/srv/ci-red/red.jsonl` (the file the CI watcher writes), `"source":"box"`; not posted alone | |
|
||||
| the daily digest | at the first pass at or after 09:00 London, one line to the updates channel: reds in 24 h, CI and box, per class, with each class's guard | the learning, read once a day |
|
||||
|
||||
## 7. What belongs on another branch
|
||||
|
||||
| Branch | One-line change |
|
||||
|---|---|
|
||||
| build-server (provision.sh) | install cargo-audit for the build user (the night battery and the ca3 lane call it; pre-flight now refuses it with a clear line instead of a 0-second exit 2) |
|
||||
| release-0.3.15 | take master (or cherry-pick 2bd3bec): `infra/build-server/repro/rebuild-on-box.sh` re-stamps its clones, else the copied-sources step fails again at the next push. Its own `tools/ci/windows-paths-check.sh` (c25a3ca) is superseded by master's: on merge keep master's file and drop the extra ci.yml step, the gate runs it |
|
||||
|
|
@ -12,25 +12,36 @@ The bar main set, and the bar this document holds every claim to: "impossible" i
|
|||
| 2 algorithm | `docs/analysis/horizon/algorithm.md`; model `sim/horizon/algorithm/model.py` | landed, commit 5ff7393 |
|
||||
| 3 finality-and-weight | `docs/analysis/horizon/finality-and-weight.md`; models `sim/horizon/finality-and-weight/` | landed, commit c3aa502 |
|
||||
| 4 economy-and-utility | `docs/analysis/horizon/economy-and-utility.md`; models `sim/horizon/economy-and-utility/` | landed, commit 4617a01 |
|
||||
| 5 network | `docs/analysis/horizon/network.md` | running (takes the 10 bps runs A, A2 and the 1 bps control B) |
|
||||
| 6 polish | `docs/analysis/horizon/polish.md` | running |
|
||||
| 5 network | `docs/analysis/horizon/network.md`; the experiment `docs/analysis/block-rate-devnet2.md` | landed, commits 3777014 and b965b64 (runs A and B; A2 dropped) |
|
||||
| 6 polish | `docs/analysis/horizon/polish.md` | landed, commit ac4cc93 (95 ledger rows) |
|
||||
| 7 frontier | `docs/analysis/horizon/frontier.md`; model `sim/horizon/frontier/frontier_model.py` | landed, commit 5ff7393 |
|
||||
| 8 new-proof-of-work | `docs/analysis/horizon/new-pow.md`; prototypes `proto-newpow/` | designs landed (5ff7393); measured rows by the afternoon of 7 October UK |
|
||||
| 8 new-proof-of-work | `docs/analysis/horizon/new-pow.md`; prototypes `proto-newpow/` | landed, commits cbcff47 and 92db7c5 (two prototypes measured on rented 4090s, verdicts in section 6) |
|
||||
|
||||
## 1. One page for the project lead
|
||||
|
||||
What the night found, in the order it matters.
|
||||
Closed 6 October 2026, 22:3x UK, every lane landed.
|
||||
|
||||
1. **The one line where a majority earns more than it spends is the proving pool, not the chain.** Consensus checks a carried proof record's signature and its statement, not the proof (spec 07, 7.7 items 3 and 4; ledger P21, decided as v0 through the public testnet). Any block producer can therefore carry its own fake-proof records and be paid its block share of the 20 percent pool: 11,636 IGN an hour at 51 percent of blocks (lane 1, `cost_model.py`). Every other attack line costs more than it earns. Fix: verify the aggregated segment proof in consensus, 8 to 12 hours, no liveness cost. Until it lands, the public text must not say the 20 percent pool is "paid to provers" without the caveat.
|
||||
2. **Tonight's two-hour finality pause was the rule working, then the frozen table holding it, and the fix is a signed exit.** Twenty keys holding 42.7 percent of the frozen voter table left in three minutes (the class v4 rehearsal job); checkpoint 6843 saw 53.1 percent of total and the 2/3 rule paused as designed; locks had formed without the hub, so topology is refuted (lane 3, from the observer rows). Rule v2 would have re-locked after 35 minutes; rule v3's frozen table holds it for a window: 2 hours on the devnet, 30 days on mainnet for the same event. Of five candidate rules simulated, only the departure announcement (a `leave` item in blocks, the key out of every denominator one hour later) keeps zero conflicting locks in every partition and eclipse AND ends the pause in under an hour (6 hours). The operational rule costs nothing: a standing box never leaves the live chain for an experiment, and any orchestrated departure over 10 percent of weight goes in slices under 10 percent an hour.
|
||||
3. **A 51 percent attacker on Igneum buys a 90-second reorder window and nothing past a certificate.** A 45 to 51 percent withholder wins the selected-chain race over a 90-s hold 70 to 85 percent of the time (32 to 46 blocks at 1 bps); the lock lands 63 to 93 s after the checkpoint block and bounds what a majority can reorder; sustained withholding lifts weight share to about 56 percent, never two thirds (lane 1, GHOSTDAG simulator mirroring `protocol.rs`, 20 seeds). The veto (one third of weight) costs 20 days at 51 percent of hash: USD 6k at 1 GH/s, USD 5.8 M at 1 TH/s, half earned back as subsidy; once held, a pause is free and a pause-time 12-hour double spend costs USD 146 to 146k. Weight-gated deep fork choice (a tip forked more than 10 minutes back is a candidate only if its builders hold a third of the weight table at the fork) and vote-or-burn (a silent key's blocks burn 20 percent of their producer share) close those two lines, 16 to 24 hours together, no liveness cost. The paper is `docs/analysis/51-percent.md`.
|
||||
4. **The chip question is settled in kind and open in degree.** The chip that matters is the stored-dataset memory-controller chip: 5.7x per joule against the 5090 at class v3, 2.1x at class v4 with a chip core as efficient as the GPU's (k = 1), 0.9x against the M5 Max (lane 2, `chip-model-v3` method). The reserve and the era draw buy about nothing against a chip (every drawn parameter is firmware; a reserve block is about USD 4 of silicon) and the public text should say what they do buy. The lever is the latency-shadow size N, and lane 2 and lane 7 agree it belongs in the era draw at genesis as a verifier-bounded ladder {100k, 130k, 200k, 330k, 650k, 1.0M}, each step by 90 percent miner signal, never unconditional (an unconditional doubling retires the M5 Max at era 1). First measured verifier proxies tonight: class v4 5.06 ms cold on a box core, 8.23 with the SMT sibling loaded (passes); dr736 10.51 and 15.49 (out); R0 is dr368.
|
||||
5. **Fees are not a security budget for a decade, and the proving price is a function of network hash.** All utility curves together pay miners and provers USD 450 a day at launch and USD 4,200 in year 5 against USD 54,800 and 13,700 of daily emission (lane 4). The price a prover must charge is the subsidy it forgoes, 1 / network hash: 100 to 300x Boundless's rate at 1.16 GH/s, 0.2 to 0.4x at 100 GH/s beside its miner. The adopted job floor overprices the market above about USD 0.014 per IGN. A ten-day prover refusal strands 547,570 IGN a day of pool credit in an escrow with no rule to return it. Fixes: decouple the job price from `f_p` (16 hours), roll unproven credit forward (8 hours), publish the price as a formula, never a number (3 hours).
|
||||
6. **The signalling window can be bought for a day.** The P2 one-day 95 percent window costs about 19 N of hash for 24 hours (USD 534k at 100 GH/s) and would force a class flip onto a fleet not yet on the object; a 6 percent holdout buys delay to the floor for nothing. Seven consecutive daily windows with the floor a week past publish fixes it, 3 hours. The three signalling thresholds (60 parameter, 90 upgrade, 95 class with floor) are stated inconsistently across spec 5.7, CLAUDE.md and the litepaper and must become one sentence.
|
||||
7. **New proof of work.** Scheme A (mining is proving) is ruled out twice over, by lane 8's design pass (2.9 MB of openings per block, a 32 to 40 ms proof verify against the 10 ms gate, sampleability) and by lane 7's prior-art pass (Ball et al. 2017, Ofelimos 2022, Aleo). Schemes B (a tensor-shaped integer shadow that forces a chip to carry a GPU-class datapath) and C (the dataset derived from the execution state, so every hash proves the miner holds the chain) are in prototype on two rented 4090s; measured rows land by the afternoon. (Section 4, lane 8, when it lands.)
|
||||
8. **The frontier list, honestly cut.** Do now: the finality weight table carried inside the recursive segment proof (a consensus proof at mergeset cost, a browser that trusts no node for the voter set; 60 hours, measure the in-guest BLS and colouring cycles first), reproducible-build attestations with Ember refusing a release under N of M (12 hours), a WASM verifier of the wrapped block proof in the tab (16 hours), Ember as node, wallet and light client for everyone (20 hours). Never: proving others' chains as the main income (all of Ethereum L1's proving is about USD 36 a day at the Sep 2026 tracker cost against USD 13,700 a day of year-1 emission at USD 0.005), the hash partly a proof, proof verify on a hardware wallet's secure element, a general unverifiable compute market, burn-redirect audit bounties (a dev fund with a veto).
|
||||
**Standing decisions (the project lead, 6 October 2026, 22:3x UK).** Verbatim: "Fees cannot fund security for a decade" and "Miners need to be the security". Meaning: miners are the security always, no time bound, no stake, no outside checkpoints or committee. The chain pays its own miners from emission plus fees; nobody pays upkeep, not the founder, not a treasury, not a dev fund. Self-sustaining means the emission curve keeps mining worth doing on its own for as long as fees are small, which lane 4 measures as a decade or more, so emission never decays on a schedule that assumes fees take over. Fee revenue is never assumed as the security budget in any model or public sentence. His third line, "Wrong constants and claims in our own text", acknowledges lane 6's finding; the nine ledger rows in item 9 are the fix.
|
||||
|
||||
Rows from lanes 5 (network: block rate, the controller's red-block feedback seen in run A, node and bandwidth tiers), 6 (polish: the ten things the project lead would notice) and 8 (the two prototypes' measured rows) join this page and the table below when they land.
|
||||
1. **The proving pool was the one line where a majority earned more than it spent**: 11,636 IGN an hour at 51 percent of blocks. Fix: the proof verified in consensus. **In 0.3.16** (fork exec-sync-0313 da2d17ec), switch `proving_consensus_verify_daa` off by default. **Decision owed:** when it activates.
|
||||
|
||||
2. **Tonight's two-hour finality pause.** 42.7 percent of the frozen voter table left in three minutes and the frozen table held the pause a window (30 days on mainnet). Fix: a signed `leave` item, the key out of every denominator an hour later. **In 0.3.16** (finality-pause-node 766e70ca, finality-pause 27f82ec). The slices-under-10-percent rule: **done**.
|
||||
|
||||
3. **A 51 percent attacker buys a 90-second reorder window and nothing past a certificate.** **Done:** the peer-driven unwrap class (8e2f5cbe, 0.3.16), the receive-side version gate (f1ea7a38, 0.3.15). Next: weight-gated deep fork choice and vote-or-burn.
|
||||
|
||||
4. **The chip is settled in kind, open in degree**: 5.7x per joule at class v3, 2.1x at v4. The lever is the latency-shadow size N as a genesis ladder, each step by 90 percent signal. Text (M32, M33): **done**. **Decision owed:** the N ladder at genesis.
|
||||
|
||||
5. **Fees stay tiny for about ten years; emission carries security, with no end date.** USD 450 a day at launch and 4,200 in year 5, against 54,800 and 13,700 of emission. Text (E19, E20, E21, P24, P25): **done**. Next: unproven credit rolled forward, the job price decoupled from `f_p`.
|
||||
|
||||
6. **The signalling window could be bought for a day.** Seven consecutive daily windows at 95 percent, the floor a week past publish: **in 0.3.16** (ca3-v4-0316 0760b844). The thresholds in one sentence (G15): **done**.
|
||||
|
||||
7. **New proof of work, measured.** Scheme A (mining is proving): never. Scheme B (tensor shadow): never as class content; kept as reserve R8. Scheme C (dataset from stored state): the class v5 candidate; hash rate and watts unchanged, build +1.4 ms, verifier +0.11 to 0.21 ms per unit.
|
||||
|
||||
8. **Block rate: 1 a second for the testnet and launch.** 10 a second on Devnet 2 ran 77.6 percent red on node cost. 10 waits behind three gates: per-block CPU under 50 ms on a laptop core, finality constants in DAA seconds, vote aggregation. **Done.**
|
||||
|
||||
9. **Polish.** Pause wording in Ember and the API: **in 0.3.16** (ember-tune b726ce4). `fork_is_close` and the publisher's activation guard: **in 0.3.16** (1357d28, 08f5276). Export-disk cap: **done** (gpu-fleet f9ad70e). Nine text corrections (M32, M33, F26, E19, E20, G15, P24, E21, P25) plus X31 to X33: **done**. Unsigned installers: **decision owed**. Relay fixes (28c028b) on fud-close: merge owed.
|
||||
|
||||
**Decisions owed from the project lead:** the cryptanalysis spend (USD 80,000 to 160,000); the testnet date word; the N ladder at genesis; the verification switch activation.
|
||||
|
||||
## 2. The ranked list: top 25 across every lane
|
||||
|
||||
|
|
@ -64,7 +75,7 @@ Rank is payoff over cost across lanes, with safety first, then liveness, then mo
|
|||
| 24 | Measure the FPGA lane on AWS F2 (one VU47P, HBM2, about USD 1.98 an hour) and replace the ceiling row | L2 r2 | the measured 2.4 G reads/s equals the JEDEC tFAW ceiling; the old 1.9x row rests on a 12 ns tFAW the JEDEC HBM2 table does not give (28 ns); the soft overlay reads 0.30x to 0.47x of the 5090 per watt | L2 5.1 | 8 to 10 plus USD 2 to 8 | none today; the public FPGA claim becomes a measured number | the overlay bench on F2 | reads per second per watt at 1 GiB; the row replaced |
|
||||
| 25 | Ember tune as the shipped default per card model, and the two fleet measurements every price rests on: the miner's hash loss while each tier proves, and a full 30 M-cycle shard beside the miner on 12 and 16 GB cards | L2 r7, L4 r8 | the 4070 at 3.65 uJ untuned and 2.57 tuned (-30 percent); the hybrid row is the only one that undercuts the market and rests on one 5090 measurement; the 4.7 M fixture is 16 percent of a full shard (linear scaling says 240 s on a 3060, outside the 120-s claim timeout) | L2 5.1; `utility.py hybrid_hash_loss` | 2 + 6 (fleet) | every NVIDIA tier gains 10 to 30 percent per joule; the 12 GB tier learns whether it can claim a full shard in time | the defaults table in the app from the fleet priors; eleven rows with both numbers in `prover-tiers-real-cards.md` | the rows land; the claim timeout is set from them |
|
||||
|
||||
Rows from lanes 5, 6 and 8 are inserted and the table re-ranked when they land (expected: the controller's red-aware correction and the block-rate recommendation from lane 5; the ten the project lead-visible polish rows from lane 6; the class v5 verdicts from lane 8).
|
||||
Lanes 5, 6 and 8 landed after this table was ranked; their rows are in the lane files (network.md section 6, polish.md section 1, new-pow.md section 7) and in the one page above. The table itself is not re-ranked: rank 1 and rank 2 are in 0.3.16, rank 3 is in 0.3.16, rank 8 is done, rank 9's unwrap half is in 0.3.16, rank 23's text bundle is done.
|
||||
|
||||
### Not recommended, with the reason (as valuable as the list above)
|
||||
|
||||
|
|
@ -122,8 +133,20 @@ The residual risks stated plainly: the first 20 days (no weight table yet); a pa
|
|||
2. Vote weight is already a slashable, non-purchasable bond: work-stake for external jobs, with "no coin stake" written into the spec first.
|
||||
3. The consensus proof can be incremental: the weight table inside the recursive segment proof, one mergeset per segment; the first measurement is the in-guest BLS verify and colouring cycle counts.
|
||||
|
||||
### Lanes 5, 6, 8
|
||||
(Filled when they land.)
|
||||
### Lane 5, network
|
||||
1. Reds come from node cost, not topology: run A at 10 blocks a second on a star ran 77.6 percent red because the hub needed 61 to 345 ms per block; the propagation model predicts under 0.1 percent red in every bps x delay cell with an ideal hub.
|
||||
2. The controller reads blue work only, so a star or a withholder eases difficulty; the whole-DAG estimator holds the rate in every cell.
|
||||
3. The recommendation: 1 block a second for the testnet and launch; 10 behind three gates (per-block CPU under 50 ms on a laptop core at mergeset 248, finality constants in DAA seconds, vote aggregation). The experiment is `docs/analysis/block-rate-devnet2.md`.
|
||||
|
||||
### Lane 6, polish
|
||||
1. The pause had no cause on any surface: no `finality_reason` or frozen-table share in the node's report, the observer, the API, Ember or the hub (Q1, Q2, Q6); the 0.3.16 Ember carrier is on ember-tune.
|
||||
2. Every update was urgent once an activation height was behind the node (`fork_is_close`, Q4), and nothing refused an activation height at or below the live DAA; both fixed on ember-tune for 0.3.16.
|
||||
3. Unsigned installers on both desktops (Q3, the project lead's certificates) and the relay's security fixes still on fud-close (Q8).
|
||||
|
||||
### Lane 8, new-proof-of-work
|
||||
1. Scheme A (mining is proving) is a bound, not a design: 2.9 MB of openings per block or a 32 to 40 ms verify against the 10 ms gate; the useful fraction is 8 percent at 1 GH/s and 0.08 percent at 100 GH/s.
|
||||
2. Scheme B (tensor shadow) is free for the honest card and so nearly free for the chip (0.056 to 0.70 pJ per multiply-add against 11 pJ per ALU op); never as class content, kept as reserve R8.
|
||||
3. Scheme C (dataset from stored state) is the class v5 candidate: the 4090's rate and watts equal within noise (63.083 against 63.088 MH/s), build +1.4 ms, verifier +0.11 to 0.21 ms per unit, bit-exact on 1,024 items and 128 lanes.
|
||||
|
||||
## 5. What was not run, and why
|
||||
|
||||
|
|
@ -134,7 +157,7 @@ The residual risks stated plainly: the first 20 days (no weight table yet); a pa
|
|||
| The FPGA soft overlay on real HBM2 | 2 | no FPGA in the fleet; AWS F2 plan written |
|
||||
| The AMD watts at every N | 2 | the ADLX sampler row is owed on the runner's `--cards-off` mechanism (counter-asic-3-status 6a) |
|
||||
| A `cargo bench` of `fast_aggregate_verify` at 93, 1,000 and 8,192 keys | 3 | the BLS figures are arithmetic on blst's published timings, approximate |
|
||||
| The 10 bps runs A2 (mesh) and B (control) | 5 | landing during the night; lane 5 reads them before it closes |
|
||||
| The 10 bps mesh run A2 | 5 | dropped: run B (the 1 bps control) landed clean and the model attributes run A's reds to node cost, which a mesh does not change |
|
||||
| The full 30 M-cycle shard beside the miner on any tier; the hash loss while proving on Ampere and Ada | 4 | the fleet measured the 4.7 M fixture only |
|
||||
| Market prices for Taiko-class batch proving and Bonsai | 4 | not published; marked approximate |
|
||||
| The observed end of tonight's pause | 3 | expected at DAA 216,402, about 20:40Z; the timeline closes when the observer rows show the lock |
|
||||
|
|
|
|||
|
|
@ -80,3 +80,7 @@ Dry run against the live API at 18:51 UTC rendered all six posts (pulse 641 char
|
|||
from the release plan. Finality was paused at render time (`finality.active` false, last lock 6842 at about 18:42Z, the newest
|
||||
checkpoints at 55% of total weight): the watcher's first condition, seen in dry run. Not yet run: a live post (no credentials
|
||||
file yet), the install on the box.
|
||||
|
||||
## The server invite
|
||||
|
||||
The standing invite is https://discord.gg/beCy8G5ZR (created 6 October 2026, main; the vanity names discord.gg/igneum and discord.gg/igneumnetwork were free on 3 October and are not yet claimed). The home page's join line and any public text use this URL; nobody asks for it again.
|
||||
|
|
|
|||
|
|
@ -614,6 +614,8 @@ Evidence: design doc, decisions table row "Who are you?". Journey: `site/journey
|
|||
|
||||
Status: Conceded, stated (5 October 2026, night): `site/litepaper.html`, Governance, "There are no admin keys in consensus"; `site/index.html`, tile "admin keys in consensus". Was: Conceded, wording fix needed.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, later that night): the home page was restored to its fuller shape on the owner's word ("revert it but polish it") and carries the tile "admin keys in consensus" again, beside the litepaper's Governance sentence.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, night): the home page was redrawn as one statement, the live scene, three facts and the downloads, so the tile "admin keys in consensus" is no longer on `site/index.html`; the sentence stands on `site/litepaper.html`, Governance ("There are no admin keys in consensus"). The 5 October line below is the history.
|
||||
|
||||
Answer: Correct. Consensus has no admin keys. The genesis apps are contracts and each will have an upgrade policy; the bridge's is the one that matters, and if it starts as a committee bridge (E7) it has keys by definition. The development fund contract named in the quote no longer exists: the fund was removed on 3 October 2026. The litepaper's sentence must be scoped to consensus and each genesis contract must publish its key policy before launch.
|
||||
|
|
@ -689,6 +691,8 @@ Evidence: litepaper "For miners", hardware paragraph. Wording: overclaims list,
|
|||
|
||||
Status: Conceded, stated (5 October 2026, night): `site/litepaper.html`, Mining, "no chip publicly shipped, approximate" and "Monero is precedent, not proof"; `site/index.html`, hero, "a chip gains too little to take your place". Was: Conceded, label needed.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, later that night): the restored home page carries the RandomX paragraph again, "since 2019 (approximate)", beside the litepaper.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, night): the home page no longer carries the RandomX paragraph; "since 2019 (approximate)" and "precedent, not proof" stand on `site/litepaper.html` (vs RandomX, Mining). The 5 October line below is the history.
|
||||
|
||||
Answer: True. "No chip publicly shipped, approximate" is the defensible phrasing. The argument from Monero is precedent, not proof, and the bounty exists because precedent is not proof.
|
||||
|
|
@ -958,6 +962,8 @@ Sweep (5 October 2026, evening): stated in part. `site/partials/footer.html` on
|
|||
|
||||
Status: Conceded, stated (5 October 2026, night): `site/litepaper.html`, Roadmap phase 6, and `site/journey.json` phase 6, "No listing is arranged, promised or sought by the project". Was: Conceded, fix now.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, later that night): the restored home page shows the journey again; phase 6 reads "No listing is arranged, promised or sought by the project" there and in the litepaper's roadmap.
|
||||
|
||||
Status: Conceded, stated (6 October 2026, night): the home page's journey is no longer shown (the inlined feed remains in the page source); the sentence "No listing is arranged, promised or sought by the project" stands on `site/litepaper.html` Roadmap phase 6 and in `site/journey.json`. The 5 October line below is the history.
|
||||
|
||||
Answer: Correct. No listing is arranged, promised or sought by the project. The phrase comes off the roadmap and the homepage journey.
|
||||
|
|
|
|||
|
|
@ -286,3 +286,44 @@ The box stays a build and test machine. If main wants a CPU prover anyway for co
|
|||
the shape is a systemd unit as `build` with `SP1_PROVER=cpu`, a throwaway devnet key (never the OTA key, never a hand's key),
|
||||
`--threads 48`, Nice 19 and the measure hold taken for the whole run, which would exclude builds for minutes at a time: that
|
||||
is why it is not written.
|
||||
|
||||
## 8. The capacity layer (6 October 2026, the project lead: "get the builder spun up to capacity")
|
||||
|
||||
The box sits idle most of the minute between builds. `infra/build-server/capacity/` is a background workload layer that
|
||||
uses the idle cores and yields to builds. It runs as `igneum-capacity.service` (user `build`, `Nice=19`, `chrt -i 0`
|
||||
SCHED_IDLE, `IOSchedulingClass=idle`, `CPUQuota=8800%` so 8 of the 96 threads are always free) and is a controller
|
||||
(`run.sh`) over a queue of jobs in `jobs/`. All work lives under `/srv/capacity`; the layer takes NO build slot and
|
||||
writes NOTHING under `/srv/builds/<worktree>`.
|
||||
|
||||
### Rules (the layer obeys these; they are why a build never waits on it)
|
||||
|
||||
| Rule | How |
|
||||
|---|---|
|
||||
| Background work never takes a build slot | the jobs run cargo directly in `/srv/capacity` and never call `remote-run.sh`; the controller's `cap_build_active` only READS `/srv/builds/_locks` with `flock -n` |
|
||||
| It pauses whenever a build slot is taken or the measure hold exists | `run.sh` polls `/srv/builds/_locks` every 5 s; it `SIGSTOP`s the running job's whole process group the instant any `build-<k>` or `measure` is held and `SIGCONT`s it when they clear; it does not even start a new slice while a build runs |
|
||||
| It never touches `/srv/builds/<worktree>` trees | its own checkout is `/srv/capacity/src` (repo) and `/srv/capacity/src/vendor/igneum-node` (fork), its targets are under `/srv/capacity`; the only `/srv/builds` access is a READ of an existing node binary and a READ of the lock files |
|
||||
| It is killed by the night battery's start and restarted after | `igneum-night-battery.service` has `ExecStartPre=+-systemctl stop igneum-capacity.service` and `ExecStopPost=+-systemctl start igneum-capacity.service` (the `+` runs as root; the `-` never fails the battery) |
|
||||
|
||||
### The jobs, in priority order (`CAP_SEQUENCE` gives the earlier ones more turns)
|
||||
|
||||
| # | Job | What | Out | Dry-run / smoke |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `pow-fuzz` | continuous `igneum-pow` mixer and scratch fuzz; `IGNEUM_FUZZ_SEED_BASE` advances from a cursor so every slice walks fresh programs; counts programs and units, saves any mismatch with its seed base | `/srv/capacity/out/pow-fuzz/<date>/` | `--dry-run` / `--smoke` (N 100, 10 min) |
|
||||
| 2 | `sync-fuzz` | the sync-request gate for the 28 unwrap sites (`docs/analysis/horizon/consensus-security.md` s5): a throwaway pruned `igneumd` on a simnet datadir on the box (net `igneum-devnet-315`, loopback only, NEVER the live devnet), fed random, boundary and below-retention locator/header/antipast/IBD/pruning-point requests by `igneum-p2p-probe sync-fuzz`; a gRPC alive check every 25 requests; any panic saved with the trace | `/srv/capacity/out/sync-fuzz/<date>/` | `--dry-run` (builds one request of each kind) / `--smoke` (6 min of requests) |
|
||||
| 3 | `sim-sweeps` | GHOSTDAG (`ghostdag_sim.py --seed-base`), finality (`finality_horizon.py`, `finality_v2.py --quick`) and difficulty attacks (`attacks.py --seed-base`) across seeds 1 to 1,000; CSV appended; a daily note of any bound that moved | `/srv/capacity/out/sim-sweeps/<date>/sweeps.csv` | `--dry-run` / `--smoke` (one seed, quick) |
|
||||
| 4 | `model-sweeps` | the N-ladder and chip model (`sim/horizon/algorithm/model.py`) over every section, cached by the file's content hash | `/srv/capacity/out/model-sweeps/<date>/` | `--dry-run` / `--smoke` |
|
||||
| 5 | `clippy-audit` | `cargo clippy` and `cargo audit` on every branch pushed to the box repo mirror in the last day, in its own checkout `/srv/capacity/clippy`, recorded per branch and commit so a slice only picks up new pushes | `/srv/capacity/out/clippy-audit/<date>/<branch>/` | `--dry-run` / `--smoke` (one crate, newest branch) |
|
||||
|
||||
Each job is a script in `jobs/` with a `--dry-run` mode (no build, no node, no run) and a `--smoke` mode (a ~10 minute
|
||||
bounded run). Every job writes one line per slice into `/srv/workers/capacity.json` (`summary.mjs`, atomic), which the
|
||||
worker dashboard's collector (`tools/workers/collect.mjs`) reads into `doc.background`; the page (`tools/workers/page`)
|
||||
shows a "Background" lane from it.
|
||||
|
||||
### Install
|
||||
|
||||
`infra/build-server/capacity/install.sh` (idempotent): copies the scripts and the unit, pushes the `capacity-probe`
|
||||
fork branch (the `sync-fuzz` subcommand) and the `box-capacity` repo branch to the box mirrors, and enables the
|
||||
service. The layer tracks the repo branch `master`; until this work merges, the mirror's `master` lacks the capacity
|
||||
tree, so install writes a drop-in pinning `CAP_REPO_BRANCH=box-capacity` and removes it once `master` carries
|
||||
`infra/build-server/capacity/run.sh` (self-healing after the merge). `--no-start` enables without starting;
|
||||
`--smoke <job>` installs then runs one job's 10-minute smoke and prints the summary.
|
||||
|
|
|
|||
104
docs/plans/ledger-decisions.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# FUD ledger: decisions for the project lead
|
||||
|
||||
Written 5 October 2026, night, by the ledger closer (branch `fud-close`). One paragraph per ledger item that cannot close without the project lead: the question, the facts the ledger already holds, the recommendation, and what the decision unblocks. The ledger entry for each says "Decision owner: the project lead" and points here. Nothing here is decided until the project lead says so; when he does, the ledger entry gets the dated "Decided" line and this file keeps the paragraph with the outcome appended.
|
||||
|
||||
Items owned by other agents tonight (C4, M20, F21 and F22 with the N3 switch, the transaction relay, GPU hot-plug) are not here.
|
||||
|
||||
## 1. M1 and M22: the ASIC challenge, its terms, judge and funding
|
||||
|
||||
Question: what the standing bounty scores, who judges it, what it pays and who pays. Facts: M1's "under 2x" is a hash-rate target with no scoring rule; M22 lists the metrics the benchmark should publish (hashes per second and per joule per program over at least 100 epochs, reported as worst decile and median, never one average; capital cost per unit of hash rate at a stated volume; a longevity term against the instruction-family reserve and the dataset growth of spec 1.13; recomputation, partial storage and weak-program selection scored separately); M16 prices the recompute attacker at 1.5x to 2.4x at equal integer budget and 3x to 6x with a fixed-function factor, with the mixer-cost lever that brings it under 1x at zero honest cost. Recommendation: publish the scoring rules with the January 2027 benchmark exactly as M22 lists them; the payer is the entity (Igneum Labs LTD), never a person; the judge is a named external reviewer paid from the review line of the funding table (item 4 below), not the team; the reward is a fixed sum in fiat announced with the rules; eligible hardware is any design with a public bill of materials and a reproducible simulation or a working part; the public claim until a design has been scored is the one M22 words ("consumer GPUs remain competitive against the best independently proposed specialised design across the tested workloads and the stated economic assumptions"). Unblocks: the M1 status moves from "Open, target" to "Open, bounty terms published, unclaimed since <date>", and the litepaper's bounty sentence gets a date.
|
||||
|
||||
## 2. F16: a lock that can become uncertified after a heal (gate 3, O-3.17)
|
||||
|
||||
Question: option A (spec 3.5 as first proposed: strike the equivocators, re-evaluate, uncertify the index if neither or both lock) or option B (spec 3.11.4: a verified certificate is never withdrawn, the node reports the conflict, finality pauses until an operator resolves it with a trusted certificate). Facts: the ledger's F16 table prices both; the state needs a 34% equivocator or a partition longer than a window; under option A every lock in that state (2 to 69 indices in the simulations, 23 on the cloud devnet) was reported locked and then withdrawn, which makes a lock a confirmation count; under option B no reported lock is ever withdrawn and the price is an operator-length pause. Note: the `c4-fix` branch (another agent, tonight) rewrites spec 3.5 with the certificate-driven reorg rule for C4; that rule is about following a certificate over a block off the node's chain and does not decide F16, but the two paragraphs sit in the same section, so the F16 text should be written after `c4-fix` merges. Recommendation: option B, as the ledger already recommends; it is Kaspa's rule for a finality conflict and the only reading under which an exchange can credit on a lock. What it needs after the decision: the 3.5 paragraph replaced by 3.11.4's text, `finality_conflict` and the `finality_active` clear in the node, the forced double-certificate test of 3.11.7. Unblocks: F16 moves to "Decided, fix scheduled"; the node work is one consensus-engineer item.
|
||||
|
||||
## 3. X5 and X14: what "independent" means for the 1,000-miner gate (O-X.1)
|
||||
|
||||
Question: adopt the definition the evening sweep wrote. Facts: the unit is a vote key above the dust count in the 30-day window; two keys are independent when they differ in all three of the autonomous system of the announcing address, the machine fingerprint the miner app sends with its log uploads, and the pool attestation; N_ind is the number of distinct classes; the gate reads "N_ind >= 1,000 over 30 days with top-10 share of window weight under 50%". Tonight's reading: 21 keys above dust are 5 machines (4.2 keys per machine) and at most 3 autonomous systems. Recommendation: adopt it as written, with one addition: a key whose machine fingerprint is absent (a miner that sends no logs) counts as its own class only if its address is in an autonomous system no other key uses, so a silent fleet cannot inflate the count. What it needs: the observer stores the autonomous system per announcing address and the fingerprint per key, and a pool statement format (a signed list of keys per pool operator). Unblocks: X5 moves to "Decided, measurement scheduled", the observer columns become one app-owner item, and the phase 5 gate in `site/journey.json` gets the definition.
|
||||
|
||||
## 4. E14: the funding table
|
||||
|
||||
Question: whether `docs/plans/funding.md` leaves PLACEHOLDER status, and which lines are published. Facts: the fund was removed by design (E4); the sources are the founder's own means today, the 1% client fee, the team's own mining, proving and apps after mainnet, no protocol fee; the cost lines in the plan are approximate estimates (a contracted cryptographer USD 80,000 to 150,000, the finality review USD 50,000 to 100,000, the node audit USD 60,000 to 120,000, from memory, approximate). Recommendation: keep the table internal until counsel has read it (L1, L2), but publish one sentence in the litepaper now that names which lines are unfunded (the second client, the external reviewers, the bounty), because E14's critic is right that "no fund by design" without a table reads as no plan. Unblocks: E14 moves from "Open, placeholder" to "Decided: internal table, public unfunded-lines sentence", and G1, G5 and M22 can point at the funded line that pays them.
|
||||
|
||||
## 5. X13: the first paying proving customer, timing and terms
|
||||
|
||||
Question: whether the paid pilot moves from phase 5 to before the public testnet, as the external reviewer asked, and on what terms. Facts: the phase 4 gate is a signed letter of intent with no payment; the brief now carries the progression (agree workload, proof format, deadline, failure rate and price before the pilot; publish the outcome and the customer's own reason; repeat purchases without reimbursement; then several unrelated customers); payment before launch needs the entity, terms and tax treatment of L4. Recommendation: keep the phase 4 gate as the signature, put the paid pilot in phase 5 as written, and do not move it earlier until counsel has answered L4; a paid pilot before the entity's terms exist is the thing L4 warns against. Unblocks: X13 stays "Open, experiment scheduled (the pilot)" with a dated phase and no earlier promise in public text.
|
||||
|
||||
## 6. L1, L2, L4, L5: counsel and the trademark search
|
||||
|
||||
Question: engage counsel in the entity's jurisdiction (DIFC) for the Howey and promotions review of the founder-business paragraph and the launch grants (L1, L2), the testnet payment arrangement (L4), and record a trademark clearance search in classes 9, 36 and 42 at EUIPO, USPTO and the UK IPO (L5). Facts: nothing is runnable by an agent; the ledger's "offshore, parked" is now an entity with an address (Igneum Labs LTD, DIFC); the inducement wording is being removed from the litepaper tonight (L2 text half, group A); the trademark search of 3 October 2026 (recorded outside the repository) found IGNIUM UK00918212492 as the obstacle. Recommendation: one engagement letter covering all four, before litepaper v0.2 and before any public repository; the trademark result recorded in the repository as a dated one-line entry (found or clear per register) with the search itself kept outside. Unblocks: L1, L2, L4 move to "Open, counsel engaged <date>"; L5 moves to "Searched <date>: <result>".
|
||||
|
||||
## 7. L3: the registrar move
|
||||
|
||||
Question: the deSEC nameserver move has started (a token sits in `~/.config/igneum`, mode 600); the Vercel records must be recreated at deSEC and every domain re-verified in the Vercel project; the registrar move waits for the transfer lock to end in December 2026. Recommendation: do the nameserver move in one sitting with every domain's Vercel verification checked afterwards, because a half-moved zone takes the site down; the December registrar choice is the project lead's (a non-US registrar that accepts the entity). Unblocks: L3 moves to "Mitigated in part (nameservers, <date>); registrar December 2026".
|
||||
|
||||
## 8. G14: the history rewrite date
|
||||
|
||||
Question: when the rewrite of `docs/plans/history-rewrite.md` runs. Facts: 291 of 363 commits carry the +0100 offset, 40 carry the personal name, the intake key is in 6 tracked files across 8 commits and the dl token in 1; the dry run on a throwaway mirror is done; the rewrite breaks every open worktree and branch and so must run when no agent is mid-work. Recommendation: the morning after the last of tonight's branches merges, with every worktree removed first and both secrets rotated regardless; the rewrite is a precondition of the public repository (fud-fixes section 5), not of the testnet. Unblocks: G14 moves to "Scheduled <date>".
|
||||
|
||||
## 9. X29: the live node's RPC on every interface
|
||||
|
||||
Question: `igneumd` on this Mac listens on `*:26610` so that PC 2 can reach it. Facts: the file modes are fixed; the live node is read-only for agents tonight. Recommendation: `--rpclisten=127.0.0.1:26610` on the Mac node and PC 2 on its own node (it runs one for mining already) or an SSH tunnel; done by the operator at the next planned restart of node 1, never mid-run. Unblocks: X29 closes once the restart is logged.
|
||||
|
||||
## 10. P9: the shard-market parameters (O-5.1, O-5.6)
|
||||
|
||||
Question: the values of the 8 assignees, the exclusive window (10 DAA s today, 25 s proposed), the job claim timeout (120 s proposed by the economy simulator) and whether external jobs carry a bond. Facts: the parameter table is written from the live numbers (P9 sweep); the simulator found the claim timeout a market parameter and recommends starting the devnet at 120 s; shards carry no bond since the sortition rule. Recommendation: take the table as the phase 4 devnet's starting values (8 assignees, 25-s window, 120-s claim timeout, no shard bond, external job bond set on the devnet) and let the devnet measurement move them; nothing in public text names a value until then. Unblocks: P9's "parameter table written" becomes "values set for phase 4".
|
||||
|
||||
## 11. P21: the SP1 verifier in consensus
|
||||
|
||||
Question: whether the node carries the SP1 SDK (the verifier inside consensus) or a bounded in-consensus verification budget, or stays on v0 (every producer verifies off the consensus path) through the public testnet. Facts: on v0 the native-execution veto stops any wrong state; the damage of an unverified record is one prover's payout; the live devnet runs v0 with every producer verifying. Recommendation: stay on v0 through the public testnet and state it in the litepaper's proving section with the label Open, because carrying the SDK in the node is a dependency decision (size, build time on the PCs, the audit surface) that belongs to the execution engineer's plan and not to a night fix. Unblocks: P21 stays "Open, stated in spec 7.7 item 4" with the public testnet as the next date rather than no date.
|
||||
|
||||
## 12. M8, M11, P16: hardware the measurements need
|
||||
|
||||
Question: whether to buy or borrow a discrete AMD card (M8: bit-exactness and the honest rate on RDNA, the one vendor not yet run), a multi-card mixed-generation rig with ROCm (M11: hourly runtime codegen on the rig a farm runs), and a 12 GB mid-range NVIDIA card (P16: the phase 2 proving gate end to end on the card the gate names). Facts: every other vendor and machine class has run (Apple, NVIDIA discrete, AMD integrated, Intel integrated); the ledger's answers on these three items are honest about the gap and nothing an agent can run on this fleet closes them; the public benchmark in January 2027 will also need them for the leaderboard. Recommendation: one discrete AMD card (a 16 GB RDNA 3 or 4 part, approximate class) and one 12 GB NVIDIA card (a 3060-class part) bought for PC 2 before the public benchmark; the multi-card rig borrowed from a farm operator for a week at the HiveOS package's first test rather than bought. Unblocks: M8 and P16 move to "measurement scheduled <date>"; M11 moves to "rig borrowed <date>".
|
||||
|
||||
## 13. F3, F17, X5: the three gate-3 parameters proposed in spec 3.4.2 (round 2)
|
||||
|
||||
Question: adopt, at gate 3, the three values the ledger-tails round wrote into `docs/spec/03-finality.md` section 3.4.2 as Proposed. Facts, from the fork's encodings and the live devnet's coinbase sizes (3.4.2 item 1): a vote item is 281 bytes, so a checkpoint's 8,192 votes at the S2 switch are 2.3 MB, 4.6x one block's compute mass, and no per-block bound lets one block carry a checkpoint; spread over the 30 blocks of a checkpoint interval the average is 274 votes per block (15.4% of the mass). The bitmap indexes the canonical voter list at one bit per key: 1,024 bytes at 8,192 voters against a 1 MiB wire bound today. The client defaults to 8 identities per large card, 2 per small, 1 on Apple silicon and integrated GPUs, which is why tonight's fleet runs 4.2 vote keys per machine. The hostile-aggregator simulation (scenario O, 3 seeds) shows the attack works under the certificate reading the simulation used until tonight and does nothing under the block reading spec 3.3 Q2 fixes. Recommendation: (a) the per-block vote bound at the value item 2 proposes, sized so a checkpoint's votes fit in its interval with headroom; (b) the bitmap wire bound at 8,192 bytes (65,536 voters, 8x the switch) in place of 1 MiB; (c) the client default of one vote key per machine (not per card or per identity), with the identity count kept as a worker setting that shares the key. Unblocks: O-3.3, O-3.5 and O-3.12 move from Proposed to Decided; the F17 and X5 Sybil arithmetic then rests on a default that matches the rule.
|
||||
|
||||
## Outcomes (6 October 2026, 17:25 UTC, the project lead's decisions, relayed by the coordinator)
|
||||
|
||||
| Item | Decision | Ledger lines written |
|
||||
|---|---|---|
|
||||
| 1 (M1, M22) | CORRECTED at 17:35 UTC: NO device bounty (the 17:25 tiers of USD 250,000 and USD 100,000 are withdrawn: a team with a real 2x chip earns more mining than any bounty, so those tiers attract nobody). The claim "under 2x" is backed by the paid independent cryptanalysis (the Monero route: four paid reviews, no bounty) and the public benchmark with M22's metrics. Optional, the project lead's call later: a single cryptanalysis prize of USD 50,000 for a published 2x+ shortcut in the mixer, the chained cache or the acceptance rule, escrowed before it is named. Every public mention of a bounty is struck from the litepaper, the evidence page, spec 06 O-1.17 and the funding plan (this commit); the Counter ASIC 2.0 document's USD 20,000-a-day issuance trigger on the `ca2-coord` branch now points at the paid cryptanalysis and the benchmark's next round (applied there in commits 6141e01 and 8c5b02f, 6 October 2026, with every other bounty sentence on that branch struck) | M1, M22
|
||||
| 2 (F16) | YES, option B | F16 |
|
||||
| 3 (X5, X14) | YES as written, with the silent-fleet addition | X5, X14 |
|
||||
| 4 (E14) | YES: internal table, one public unfunded-lines sentence | E14 |
|
||||
| 5 (X13) | YES: the pilot stays in phase 5 | X13 |
|
||||
| 6 (L1, L2, L4, L5) | IN PROGRESS: counsel engaged | L1, L2, L4, L5 |
|
||||
| 7 (L3) | YES | L3 |
|
||||
| 8 (G14) | YES: the morning after the last branch merges | G14 |
|
||||
| 9 (X29) | YES at the next planned node 1 restart: localhost bind, the wallet's node too | X29 |
|
||||
| 10 (P9) | YES | P9 |
|
||||
| 11 (P21) | YES: v0 through the public testnet | P21 |
|
||||
| 12 (M8, M11, P16) | DONE 6 October 2026: a 9070 XT and a 4070 on order; the mixed rig borrowed later | M8, M11, P16 |
|
||||
| 13 (F3, F17, X5 spec 3.4.2) | YES | F3, F17 |
|
||||
|
||||
Public text that follows from these and is not yet written (next round): the unfunded-lines sentence in the litepaper Economics (item 4); spec 3.4.2 moved from Proposed to Decided and spec 3.5 replaced by 3.11.4's text after `c4-fix` merges (items 2 and 13).
|
||||
|
||||
|
||||
## Standing decisions (6 October 2026, 22:3x UK, the project lead, at the Horizon close)
|
||||
|
||||
Recorded by the Horizon closer (branch `horizon-close`) from the project lead's three lines of 22:3x UK, verbatim first, meaning after. The one page is `docs/analysis/horizon-2026-10.md` section 1; the measurements are lane 4 (`docs/analysis/horizon/economy-and-utility.md`) and lane 6 (`docs/analysis/horizon/polish.md`).
|
||||
|
||||
| Line, verbatim | Standing decision |
|
||||
|---|---|
|
||||
| "Fees cannot fund security for a decade" | Fee revenue is never assumed as the security budget in any model or public sentence. Lane 4 measures fees at USD 450 a day at launch and USD 4,200 a day in year 5 against USD 54,800 and 13,700 of daily emission, so fees stay small for a decade or more. Self-sustaining means the emission curve keeps mining worth doing on its own for as long as fees are small; emission never decays on a schedule that assumes fees take over. the project lead rejected "first decade" as a bound: there is no end date on emission carrying security. |
|
||||
| "Miners need to be the security" | Miners are the security always: no time bound, no stake, no outside checkpoints, no committee, no external security of any kind in the design (the 3 October rulings against stake and Bitcoin anchoring stand). The chain pays its own miners from emission plus fees; nobody pays upkeep, not the founder, not a treasury, not a dev fund. |
|
||||
| "Wrong constants and claims in our own text" | the project lead's acknowledgement of lane 6's finding (polish.md: the public text carried constants and claims that did not match the spec or the measurements). The fix is the nine ledger rows M32, M33, F26, E19, E20, G15, P24, E21, P25, each Conceded and stated on master on 6 October 2026, plus X31 to X33 (the testnet date, the roadmap months, the benchmark month). |
|
||||
|
||||
Still owed from the project lead after the close: the cryptanalysis spend (funding.md: two independent reviews, USD 80,000 to 160,000, before the testnet genesis); the testnet date word (the site says "weeks away"); the N ladder at genesis (the era-draw ladder of the algorithm lane, each step by 90 percent signal); the activation of the verification switch `proving_consensus_verify_daa` (off by default in 0.3.16).
|
||||
|
||||
## Decisions (6 October 2026, 23:2x UK), the project lead's answers to the seven questions
|
||||
|
||||
| # | Question | the project lead's word | Meaning |
|
||||
|---|---|---|---|
|
||||
| 1 | Emission shape | "As i said, find a solution" | The tail is the baseline; the economy lane models the revolutionary candidates (thermostat, settled-value targeting, the hybrid) and the supply, coins-per-block and halving comparison against Kaspa and the field; nothing that penalises a holder; the recommended shape ships as genesis parameters with code. |
|
||||
| 2 | Latency-shadow ladder | Explanation requested | Answer pending; the six-step ladder, every step by 90 percent signal, never unconditional, verifier-bounded at 10 ms, is being implemented behind its switch meanwhile. |
|
||||
| 3 | Proof verification in consensus | On | `proving_consensus_verify_daa` = 0 in the testnet genesis. Devnet stays off. |
|
||||
| 4 | Finality leave item | On | `finality_leave_activation_daa` = 0 in the testnet genesis. Devnet stays never until the 95 percent signal. |
|
||||
| 5 | Base unit | 18 | 18 decimals on the testnet (O-2.6 Decided, pending the implementation lane's gate); the devnet keeps 8. |
|
||||
| 6 | Cryptanalysis spend | Yes | An outside team attacks the hash class after class v4 has run a week of real hash; bounded (lane 2's USD 80,000 to 160,000); the procurement plan is owed. |
|
||||
| 7 | Testnet date | Leave open | No month anywhere; the go checklist is the date. |
|
||||
|
||||
Standing rulings of the same hour: "We dont want to penalise holders" (dormant-coin rent and anything that takes from a balance or taxes inactivity is refused for ever); vote-or-burn is implemented only if 95 percent or more of the mining community would respect it (the weigh-up lane decides between the burn and the signing bonus).
|
||||
BIN
docs/plans/site-ui-3-shots/after-v2/home-1440-dark-fold.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-1440-dark.jpg
Normal file
|
After Width: | Height: | Size: 871 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-1440-light-fold.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-1440-light.jpg
Normal file
|
After Width: | Height: | Size: 876 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-390-dark-fold.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-390-dark.jpg
Normal file
|
After Width: | Height: | Size: 699 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-390-light-fold.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-390-light.jpg
Normal file
|
After Width: | Height: | Size: 708 KiB |
BIN
docs/plans/site-ui-3-shots/after-v2/home-steps-1440-dark.webm
Normal file
BIN
docs/plans/site-ui-3-shots/after-v3/app-1440-dark.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/app-1440-light.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/app-390-dark.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/app-390-light.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/home-1440-dark-fold.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/home-1440-light-fold.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/home-390-dark-fold.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
docs/plans/site-ui-3-shots/after-v3/home-390-light-fold.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -129,6 +129,9 @@ fn write_pack_with_bases(dir: &PathBuf, e: &Epoch, day: &str, bases: &[u32], sou
|
|||
#[test]
|
||||
fn fuzz_v3_programs_cpu() {
|
||||
let n: usize = std::env::var("IGNEUM_MIXER_FUZZ").ok().and_then(|s| s.parse().ok()).unwrap_or(200);
|
||||
// IGNEUM_FUZZ_SEED_BASE (default 0) offsets the seed index so a continuous fuzzer (the box's capacity layer,
|
||||
// infra/build-server/capacity) walks fresh programs round after round; the default run is unchanged
|
||||
let base: usize = std::env::var("IGNEUM_FUZZ_SEED_BASE").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let out = std::env::var("IGNEUM_MIXER_PACKS_OUT").ok().map(PathBuf::from);
|
||||
// IGNEUM_MIXER_CLASS=mx8 fuzzes the x8 candidate as a load class (generator 2 with the class in the id); the
|
||||
// default is V3_CLASS through the seam; IGNEUM_MIXER_ERA composes a test era over the class (class_under_test)
|
||||
|
|
@ -142,7 +145,7 @@ fn fuzz_v3_programs_cpu() {
|
|||
let mut manifest = String::from("pack\tlog2\tprogram_id\tbases\n");
|
||||
let mut units = 0usize;
|
||||
let mut wraps = 0usize;
|
||||
for i in 0..n {
|
||||
for i in base..base + n {
|
||||
let seed = format!("igneum-mixer-fuzz/{i}");
|
||||
let p = program_of(&seed, class, era);
|
||||
contract(&p, &seed, class, era);
|
||||
|
|
@ -173,7 +176,7 @@ fn fuzz_v3_programs_cpu() {
|
|||
}
|
||||
mh.insert(log2, e.dataset);
|
||||
}
|
||||
println!("fuzz: {n} {} programs, {units} units on the CPU, {wraps} units in the top 256 nonces", class.name());
|
||||
println!("fuzz: {n} {} programs, {units} units on the CPU, {wraps} units in the top 256 nonces, seeds {base}..{}", class.name(), base + n);
|
||||
assert_eq!(units, 4 * n);
|
||||
assert_eq!(wraps, n);
|
||||
if let Some(dir) = &out {
|
||||
|
|
|
|||
|
|
@ -645,6 +645,8 @@ fn contract(p: &Program) {
|
|||
#[test]
|
||||
fn fuzz_scr_programs_cpu() {
|
||||
let n: usize = std::env::var("IGNEUM_SCRATCH_FUZZ").ok().and_then(|s| s.parse().ok()).unwrap_or(200);
|
||||
// IGNEUM_FUZZ_SEED_BASE (default 0): the seed index offset for the continuous fuzzer (infra/build-server/capacity)
|
||||
let base: usize = std::env::var("IGNEUM_FUZZ_SEED_BASE").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let out = std::env::var("IGNEUM_SCRATCH_PACKS_OUT").ok().map(PathBuf::from);
|
||||
let mut rng = SplitMix64::new(0x6967_6e65_756d_2d73); // "igneum-s"
|
||||
let day = "2026-10-03";
|
||||
|
|
@ -676,7 +678,7 @@ fn fuzz_scr_programs_cpu() {
|
|||
}
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
for i in base..base + n {
|
||||
let name = CLASSES[rng.below(CLASSES.len() as u64) as usize];
|
||||
let c = class(name);
|
||||
let seed = format!("igneum-scratch-fuzz/{i}");
|
||||
|
|
@ -721,7 +723,7 @@ fn fuzz_scr_programs_cpu() {
|
|||
}
|
||||
let mut classes: Vec<_> = per_class.iter().collect();
|
||||
classes.sort();
|
||||
println!("fuzz: {n} programs, {units} units on the CPU, {wraps} units in the top 256 nonces, classes {classes:?}");
|
||||
println!("fuzz: {n} programs, {units} units on the CPU, {wraps} units in the top 256 nonces, classes {classes:?}, seeds {base}..{}", base + n);
|
||||
assert_eq!(units, 4 * n);
|
||||
assert_eq!(wraps, n, "every program has a unit in the top 256 nonces");
|
||||
if let Some(dir) = &out {
|
||||
|
|
|
|||
38
infra/build-server/capacity/igneum-capacity.service
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# igneum-build-1: the background capacity layer (infra/build-server/capacity/run.sh). Uses idle cores and yields to
|
||||
# builds. Installed by infra/build-server/capacity/install.sh. User build, Nice 19, SCHED_IDLE (chrt -i 0), idle IO,
|
||||
# and a CPU quota that leaves 8 of the 96 threads free for ssh, rsync and the system. The controller itself pauses
|
||||
# every job (SIGSTOP) the instant a build slot or the measure hold is taken, so a build never waits on this.
|
||||
[Unit]
|
||||
Description=Igneum background capacity layer (pow fuzz, sync fuzz, sim and model sweeps, clippy and audit; yields to builds)
|
||||
After=network-online.target local-fs.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=build
|
||||
Group=build
|
||||
Environment=HOME=/home/build
|
||||
Environment=PATH=/home/build/.cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=SCCACHE_DIR=/srv/sccache
|
||||
Environment=IGNEUM_BUILD_SLOTS_DIR=/srv/builds/_locks
|
||||
Environment=IGNEUM_BUILD_ROOT=/srv/builds
|
||||
Environment=IGNEUM_CAPACITY_JSON=/srv/workers/capacity.json
|
||||
WorkingDirectory=/srv/capacity
|
||||
# SCHED_IDLE for the whole tree: a runnable build thread always preempts it
|
||||
ExecStart=/usr/bin/chrt -i 0 /bin/bash /srv/capacity/bin/run.sh
|
||||
Nice=19
|
||||
CPUSchedulingPolicy=idle
|
||||
IOSchedulingClass=idle
|
||||
# the hard ceiling: 88 of 96 threads, so 8 are always free even if the controller's pause lagged
|
||||
CPUQuota=8800%
|
||||
CPUWeight=1
|
||||
MemoryMax=96G
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
# a stop (and the night battery's pre-stop) must reach the whole job tree, not just run.sh
|
||||
KillMode=control-group
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
63
infra/build-server/capacity/install.sh
Executable file
|
|
@ -0,0 +1,63 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install or refresh the background capacity layer on igneum-build-1 from this Mac. Idempotent.
|
||||
# infra/build-server/capacity/install.sh copy the scripts and the unit, push the probe branch, enable the service
|
||||
# infra/build-server/capacity/install.sh --no-start install but do not start (enable only)
|
||||
# infra/build-server/capacity/install.sh --smoke <job> install, then run one job's 10-minute smoke on the box and print its summary
|
||||
# Needs root over ssh (root@<ip> with ~/.ssh/igneum_ed25519); the host ip comes from ~/.config/igneum/build-server.
|
||||
# The layer takes no build slot and writes only under /srv/capacity and /srv/workers/capacity.json.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/../../.." && pwd)"
|
||||
KEY="${IGNEUM_BUILD_KEY:-$HOME/.ssh/igneum_ed25519}"
|
||||
HOST_LINE="$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" | tr -d '[:space:]')"
|
||||
IP="${HOST_LINE#*@}"; [ -n "$IP" ] || { echo "no build server in ~/.config/igneum/build-server" >&2; exit 1; }
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10)
|
||||
NOSTART=0; SMOKE_JOB=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --no-start) NOSTART=1;; --smoke) SMOKE_JOB="$2"; shift;; *) echo "unknown arg $1" >&2; exit 2;; esac; shift; done
|
||||
|
||||
echo "capacity: installing on $IP"
|
||||
# the probe branch (igneum-p2p-probe sync-fuzz) must exist on the node mirror so the box can check it out. The branch
|
||||
# may live in this worktree's fork, or in the shared main checkout's fork (both share the same git); use whichever has it.
|
||||
FORK="$ROOT/vendor/igneum-node-capacity"
|
||||
[ -d "$FORK/.git" ] || git -C "$FORK" rev-parse -q --verify capacity-probe >/dev/null 2>&1 || FORK="$ROOT/vendor/igneum-node"
|
||||
[ -d "$FORK" ] || FORK="$(cd "$ROOT/.." && pwd)/igneum/vendor/igneum-node"
|
||||
if [ -d "$FORK" ] && git -C "$FORK" rev-parse -q --verify capacity-probe >/dev/null 2>&1; then
|
||||
GIT_SSH_COMMAND="ssh -i $KEY -o BatchMode=yes" git -C "$FORK" push -q --force build capacity-probe && echo "capacity: pushed capacity-probe to the node mirror" || echo "capacity: WARNING push of capacity-probe failed (the box will fall back to the newest release-*-node)" >&2
|
||||
else
|
||||
echo "capacity: note: no capacity-probe branch in $FORK; the box will fall back to the newest release-*-node for the sync-fuzz probe" >&2
|
||||
fi
|
||||
|
||||
# directories owned by build
|
||||
"${SSH[@]}" "root@$IP" 'install -d -o build -g build /srv/capacity /srv/capacity/bin /srv/capacity/bin/jobs /srv/capacity/out /srv/capacity/state /srv/capacity/log; install -d -o build -g build /srv/workers'
|
||||
# the scripts
|
||||
scp -q -i "$KEY" "$HERE/run.sh" "$HERE/lib.sh" "$HERE/summary.mjs" "build@$IP:/srv/capacity/bin/"
|
||||
scp -q -i "$KEY" "$HERE/jobs/"*.sh "build@$IP:/srv/capacity/bin/jobs/"
|
||||
"${SSH[@]}" "root@$IP" 'chown -R build:build /srv/capacity/bin; chmod +x /srv/capacity/bin/run.sh /srv/capacity/bin/jobs/*.sh'
|
||||
# the unit, and the night battery unit (it now kills and restarts the layer)
|
||||
scp -q -i "$KEY" "$HERE/igneum-capacity.service" "root@$IP:/etc/systemd/system/"
|
||||
if [ -f "$HERE/../night/igneum-night-battery.service" ]; then scp -q -i "$KEY" "$HERE/../night/igneum-night-battery.service" "root@$IP:/etc/systemd/system/"; fi
|
||||
# the layer tracks the repo branch master in production. Until this work merges, the box mirror's master lacks
|
||||
# infra/build-server/capacity and the sim seed-base edits, so point the layer at box-capacity with a drop-in and push
|
||||
# that branch. The drop-in removes itself once master carries the capacity run.sh (self-healing after the merge).
|
||||
REPO_SRC="$ROOT"; [ -f "$REPO_SRC/infra/build-server/capacity/run.sh" ] || REPO_SRC="$(cd "$ROOT/.." && pwd)/igneum"
|
||||
if git -C "$REPO_SRC" rev-parse -q --verify box-capacity >/dev/null 2>&1; then
|
||||
GIT_SSH_COMMAND="ssh -i $KEY -o BatchMode=yes" git -C "$REPO_SRC" push -q --force build box-capacity 2>/dev/null \
|
||||
&& echo "capacity: pushed box-capacity to the repo mirror" || echo "capacity: WARNING could not push box-capacity (is the build remote wired? run-from-mac.sh)" >&2
|
||||
fi
|
||||
if "${SSH[@]}" "build@$IP" 'git -C /srv/igneum.git cat-file -e master:infra/build-server/capacity/run.sh 2>/dev/null'; then
|
||||
"${SSH[@]}" "root@$IP" 'rm -f /etc/systemd/system/igneum-capacity.service.d/branch.conf; rmdir /etc/systemd/system/igneum-capacity.service.d 2>/dev/null || true'
|
||||
echo "capacity: master carries the capacity layer; the layer tracks master"
|
||||
else
|
||||
"${SSH[@]}" "root@$IP" 'install -d /etc/systemd/system/igneum-capacity.service.d; printf "[Service]\nEnvironment=CAP_REPO_BRANCH=box-capacity\n" > /etc/systemd/system/igneum-capacity.service.d/branch.conf'
|
||||
echo "capacity: master does not carry the capacity layer yet; drop-in pins CAP_REPO_BRANCH=box-capacity until the merge"
|
||||
fi
|
||||
"${SSH[@]}" "root@$IP" 'systemctl daemon-reload; systemctl enable --quiet igneum-capacity.service 2>/dev/null || true'
|
||||
|
||||
if [ -n "$SMOKE_JOB" ]; then
|
||||
echo "capacity: smoke $SMOKE_JOB (up to ~10 min)"
|
||||
"${SSH[@]}" "build@$IP" "IGNEUM_CAPACITY_JSON=/srv/workers/capacity.json nice -n 19 chrt -i 0 bash /srv/capacity/bin/jobs/$SMOKE_JOB.sh --smoke; echo '--- capacity.json ---'; cat /srv/workers/capacity.json"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$NOSTART" = 1 ]; then echo "capacity: installed and enabled, NOT started (--no-start)"; exit 0; fi
|
||||
"${SSH[@]}" "root@$IP" 'systemctl restart igneum-capacity.service; sleep 2; systemctl is-active igneum-capacity.service; systemctl --no-pager --lines=0 status igneum-capacity.service | sed -n 1,3p'
|
||||
echo "capacity: running; journalctl -u igneum-capacity -n 30; summary at /srv/workers/capacity.json"
|
||||
56
infra/build-server/capacity/jobs/clippy-audit.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Capacity job 5 (lowest priority): cargo clippy and cargo audit on every branch pushed to the box mirror within the
|
||||
# last day, results per branch. Uses its own checkout (CAP_ROOT/clippy) so it never disturbs the other jobs' tree, and
|
||||
# records which branches it has already done at a given commit so a slice only picks up new pushes.
|
||||
# jobs/clippy-audit.sh --dry-run list the branches it would check; run nothing
|
||||
# jobs/clippy-audit.sh --smoke clippy+audit one crate of the newest branch (~10 min)
|
||||
# jobs/clippy-audit.sh --slice-s N check branches until about this long elapses
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=clippy-audit; export CAP_PRIORITY=5
|
||||
. "$HERE/../lib.sh"
|
||||
|
||||
DRY=0; SMOKE=0; SLICE_S="${CAP_SLICE_S:-2400}"; DAYS="${CAP_CLIPPY_DAYS:-1}"
|
||||
while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1;; --smoke) SMOKE=1; SLICE_S=600;; --slice-s) SLICE_S="$2"; shift;; *) cap_say "unknown arg $1";; esac; shift; done
|
||||
|
||||
since=$(( $(date +%s) - DAYS * 86400 ))
|
||||
# branches pushed to the repo mirror within the window, newest first
|
||||
mapfile -t BRANCHES < <(git -C "$REPO_MIRROR" for-each-ref --sort=-committerdate --format='%(refname:short) %(committerdate:unix) %(objectname:short)' refs/heads 2>/dev/null | awk -v s="$since" '$2 >= s {print $1" "$3}')
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
cap_say "DRY RUN: branches pushed in the last $DAYS day(s): ${#BRANCHES[@]}"
|
||||
for b in "${BRANCHES[@]}"; do echo " $b"; done >&2
|
||||
printf '{"state":"idle","summary":%s,"counters":{"branches_due":%s,"checked":0},"dry_run":true}' "$(cap_json_str "dry run: ${#BRANCHES[@]} branch(es) in the last $DAYS day")" "${#BRANCHES[@]}" | cap_summary clippy-audit
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CLIP="$CAP_ROOT/clippy"; mkdir -p "$CLIP"
|
||||
[ -d "$CLIP/.git" ] || git clone -q "$REPO_MIRROR" "$CLIP" >/dev/null 2>&1 || { cap_say "clone failed"; exit 1; }
|
||||
git -C "$CLIP" fetch -q origin '+refs/heads/*:refs/remotes/origin/*' 2>/dev/null || true
|
||||
OUT=$(cap_out_dir clippy-audit); done_file="$CAP_STATE/clippy-done.tsv"; touch "$done_file"
|
||||
export CARGO_TARGET_DIR="$CLIP/target-capacity"
|
||||
CRATES="${CAP_CLIPPY_CRATES:-igneum-pow igneum-census pool proto-vdf}"
|
||||
[ "$SMOKE" = 1 ] && CRATES="igneum-pow"
|
||||
|
||||
deadline=$(( $(date +%s) + SLICE_S )); checked=0; warn_total=0; err_total=0; audit_vulns=0
|
||||
for entry in "${BRANCHES[@]}"; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || break
|
||||
b="${entry% *}"; sha="${entry#* }"
|
||||
grep -qF "$b $sha" "$done_file" && continue
|
||||
git -C "$CLIP" checkout -q -- . 2>/dev/null || true
|
||||
git -C "$CLIP" checkout -q -B "cap-$b" "origin/$b" 2>/dev/null || { cap_say "cannot check out $b"; continue; }
|
||||
bdir="$OUT/$b"; mkdir -p "$bdir"; bw=0; be=0
|
||||
for c in $CRATES; do
|
||||
[ -f "$CLIP/$c/Cargo.toml" ] || continue
|
||||
[ "$(date +%s)" -lt "$deadline" ] || break
|
||||
if cap_cargo_t 1200 "$CLIP/$c" clippy --release --all-targets > "$bdir/clippy-$c.log" 2>&1; then :; else be=$((be + 1)); fi
|
||||
bw=$((bw + $(grep -c '^warning: ' "$bdir/clippy-$c.log" 2>/dev/null) + 0))
|
||||
if cap_cargo_t 300 "$CLIP/$c" audit --color never > "$bdir/audit-$c.log" 2>&1; then :; else audit_vulns=$((audit_vulns + $(grep -c '^Crate:' "$bdir/audit-$c.log" 2>/dev/null) + 0)); fi
|
||||
done
|
||||
echo -e "$b\t$sha\t$(date -u +%H:%M:%SZ)\twarnings=$bw\terrors=$be" >> "$done_file"
|
||||
warn_total=$((warn_total + bw)); err_total=$((err_total + be)); checked=$((checked + 1))
|
||||
cap_say "$b @ $sha: $bw clippy warnings, $be clippy errors"
|
||||
done
|
||||
sum="checked $checked branch(es) this slice of ${#BRANCHES[@]} due; $warn_total clippy warnings, $err_total clippy errors, $audit_vulns audit advisories"
|
||||
printf '{"state":"ran","summary":%s,"counters":{"branches_due":%s,"checked":%s,"warnings":%s,"errors":%s,"audit_advisories":%s},"out":%s}' \
|
||||
"$(cap_json_str "$sum")" "${#BRANCHES[@]}" "$checked" "$warn_total" "$err_total" "$audit_vulns" "$(cap_json_str "$OUT")" | cap_summary clippy-audit
|
||||
cap_say "$sum"
|
||||
36
infra/build-server/capacity/jobs/model-sweeps.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env bash
|
||||
# Capacity job 4: the N-ladder and chip model sweeps (sim/horizon/algorithm/model.py) over the parameter grid, cached.
|
||||
# model.py is pure arithmetic on cited inputs and prints every section as markdown; this job runs each section, caches
|
||||
# the output under the out dir keyed by the model.py content hash, and only re-runs a section when model.py changed.
|
||||
# jobs/model-sweeps.sh --dry-run print the plan; run nothing
|
||||
# jobs/model-sweeps.sh --smoke run every section once (seconds)
|
||||
# jobs/model-sweeps.sh --slice-s N same as a normal run (the model is fast); ignored beyond caching
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=model-sweeps; export CAP_PRIORITY=4
|
||||
. "$HERE/../lib.sh"
|
||||
|
||||
DRY=0; while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1;; --smoke) ;; --slice-s) shift;; *) cap_say "unknown arg $1";; esac; shift; done
|
||||
SECTIONS="${CAP_MODEL_SECTIONS:-chip fpga v5 ladder era verifier schedule}"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
cap_say "DRY RUN: would run model.py sections: $SECTIONS, cached by content hash"
|
||||
printf '{"state":"idle","summary":%s,"counters":{"sections":0,"ran":0,"cached":0},"dry_run":true}' "$(cap_json_str "dry run: sections $SECTIONS")" | cap_summary model-sweeps
|
||||
exit 0
|
||||
fi
|
||||
cap_sync_checkout >/dev/null 2>&1 || true; [ -f "$CAP_SRC/sim/horizon/algorithm/model.py" ] || { cap_say "no checkout"; exit 1; }
|
||||
MODEL="$CAP_SRC/sim/horizon/algorithm/model.py"
|
||||
OUT=$(cap_out_dir model-sweeps)
|
||||
h=$(sha256sum "$MODEL" | cut -c1-16)
|
||||
ran=0; cached=0; total=0
|
||||
for sec in $SECTIONS; do
|
||||
total=$((total + 1))
|
||||
cache="$CAP_OUT_ROOT/model-sweeps/cache/$sec-$h.md"; mkdir -p "$(dirname "$cache")"
|
||||
if [ -f "$cache" ]; then cached=$((cached + 1)); cp "$cache" "$OUT/$sec.md"; continue; fi
|
||||
if timeout 300 python3 "$MODEL" --section "$sec" > "$OUT/$sec.md" 2>>"$CAP_LOG/model.log"; then
|
||||
cp "$OUT/$sec.md" "$cache"; ran=$((ran + 1))
|
||||
else cap_say "section $sec FAILED"; echo "FAILED" > "$OUT/$sec.md"; fi
|
||||
done
|
||||
sum="$total sections (model hash $h): $ran ran, $cached from cache"
|
||||
printf '{"state":"ran","summary":%s,"counters":{"sections":%s,"ran":%s,"cached":%s},"model_hash":%s,"out":%s}' \
|
||||
"$(cap_json_str "$sum")" "$total" "$ran" "$cached" "$(cap_json_str "$h")" "$(cap_json_str "$OUT")" | cap_summary model-sweeps
|
||||
cap_say "$sum"
|
||||
79
infra/build-server/capacity/jobs/pow-fuzz.sh
Executable file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env bash
|
||||
# Capacity job 1 (highest priority): continuous igneum-pow fuzz. Builds the two fuzz test binaries once into the
|
||||
# layer's own target dir, then runs them with IGNEUM_FUZZ_SEED_BASE advancing from a cursor so every slice walks fresh
|
||||
# programs. Reads igneum-pow/tests (the mixer and scratch fuzz harnesses the night battery also calls). Counts programs
|
||||
# generated and units (vectors) checked; any mismatch or panic is saved with its seed under the out dir and counted.
|
||||
# jobs/pow-fuzz.sh --dry-run build nothing, run nothing; print the plan and the cursor; exit 0
|
||||
# jobs/pow-fuzz.sh --smoke a ~10 minute bounded run (small N, one build)
|
||||
# jobs/pow-fuzz.sh --slice-s 3600 run fuzz for about this long, then write the summary and exit (the controller's slice)
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=pow-fuzz; export CAP_PRIORITY=1
|
||||
. "$HERE/../lib.sh"
|
||||
|
||||
DRY=0; SMOKE=0; SLICE_S="${CAP_SLICE_S:-3600}"; N_PER="${CAP_POW_N:-250}"
|
||||
while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1;; --smoke) SMOKE=1; SLICE_S=600; N_PER=100;; --slice-s) SLICE_S="$2"; shift;; *) cap_say "unknown arg $1";; esac; shift; done
|
||||
|
||||
base=$(cap_cursor_get pow-fuzz 0)
|
||||
if [ "$DRY" = 1 ]; then
|
||||
cap_say "DRY RUN: would build igneum-pow mixer+scratch fuzz binaries in $CAP_SRC/igneum-pow, then run IGNEUM_FUZZ_SEED_BASE=$base IGNEUM_MIXER_FUZZ=$N_PER IGNEUM_SCRATCH_FUZZ=$N_PER"
|
||||
printf '{"state":"idle","summary":%s,"counters":{"programs":0,"vectors":0,"panics":0},"dry_run":true,"next_seed_base":%s}' "$(cap_json_str "dry run: next seed base $base, N $N_PER")" "$base" | cap_summary pow-fuzz
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cap_sync_checkout >/dev/null 2>&1 || true; [ -d "$CAP_SRC/igneum-pow" ] || { cap_say "no checkout"; exit 1; }
|
||||
OUT=$(cap_out_dir pow-fuzz)
|
||||
POW="$CAP_SRC/igneum-pow"
|
||||
export CARGO_TARGET_DIR="$CAP_ROOT/target/pow"
|
||||
|
||||
# build the two fuzz test binaries once (cargo test --no-run); the layer's SCHED_IDLE cargo yields to real builds
|
||||
cap_say "building igneum-pow fuzz binaries (seed base $base, N $N_PER, slice ${SLICE_S}s)"
|
||||
if ! cap_cargo "$POW" test --release --no-run --test mixer --test scratch > "$CAP_LOG/pow-build.log" 2>&1; then
|
||||
cap_say "build FAILED (see $CAP_LOG/pow-build.log)"
|
||||
printf '{"state":"error","summary":%s,"counters":{"panics":0}}' "$(cap_json_str "build failed: $(grep -m1 '^error' "$CAP_LOG/pow-build.log" | cut -c1-120)")" | cap_summary pow-fuzz
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deadline=$(( $(date +%s) + SLICE_S ))
|
||||
total_programs=$(cap_cursor_get pow-fuzz-programs 0)
|
||||
total_vectors=$(cap_cursor_get pow-fuzz-vectors 0)
|
||||
panics=$(cap_cursor_get pow-fuzz-panics 0)
|
||||
slice_programs=0; slice_vectors=0; rounds=0
|
||||
mani="$OUT/fuzz.tsv"; [ -f "$mani" ] || echo -e "utc\tseed_base\tn\ttest\tprograms\tunits\tresult" > "$mani"
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
log="$CAP_LOG/pow-slice.log"
|
||||
if IGNEUM_FUZZ_SEED_BASE=$base IGNEUM_MIXER_FUZZ=$N_PER IGNEUM_SCRATCH_FUZZ=$N_PER \
|
||||
cap_cargo "$POW" test --release --test mixer --test scratch -- fuzz --nocapture > "$log" 2>&1; then
|
||||
# the fuzz lines: "fuzz: <n> ... programs, <units> units ... seeds <a>..<b>"
|
||||
while IFS= read -r line; do
|
||||
n=$(echo "$line" | grep -oE '^fuzz: [0-9]+' | grep -oE '[0-9]+' | head -1)
|
||||
units=$(echo "$line" | grep -oE '[0-9]+ units' | grep -oE '[0-9]+' | head -1)
|
||||
[ -n "$n" ] || continue
|
||||
which=mixer; echo "$line" | grep -q 'classes' && which=scratch
|
||||
slice_programs=$((slice_programs + n)); slice_vectors=$((slice_vectors + ${units:-0}))
|
||||
echo -e "$(date -u +%H:%M:%S)\t$base\t$N_PER\t$which\t$n\t${units:-0}\tpass" >> "$mani"
|
||||
done < <(grep '^fuzz:' "$log")
|
||||
else
|
||||
# a mismatch or a panic: save the whole log with the seed base, count it, stop the slice
|
||||
panics=$((panics + 1))
|
||||
save="$OUT/mismatch-seedbase-$base-$(date -u +%H%M%S).log"; cp "$log" "$save"
|
||||
echo -e "$(date -u +%H:%M:%S)\t$base\t$N_PER\tboth\t0\t0\tMISMATCH:$save" >> "$mani"
|
||||
cap_say "MISMATCH at seed base $base, saved $save"
|
||||
break
|
||||
fi
|
||||
base=$((base + N_PER)); rounds=$((rounds + 1))
|
||||
cap_cursor_set pow-fuzz "$base"
|
||||
done
|
||||
# cumulative totals: the cursor holds the running total across slices; this slice adds its own
|
||||
total_programs=$(cap_cursor_get pow-fuzz-programs 0); total_programs=$((total_programs + slice_programs))
|
||||
total_vectors=$(cap_cursor_get pow-fuzz-vectors 0); total_vectors=$((total_vectors + slice_vectors))
|
||||
cap_cursor_set pow-fuzz-programs "$total_programs"; cap_cursor_set pow-fuzz-vectors "$total_vectors"; cap_cursor_set pow-fuzz-panics "$panics"
|
||||
|
||||
# list the saved mismatch logs as a JSON array directly from the directory (no pipe: pipefail would run a `|| echo` too)
|
||||
saved_json=$("$NODE_BIN" -e 'const fs=require("fs"),d=process.argv[1];let l=[];try{l=fs.readdirSync(d).filter(f=>f.startsWith("mismatch-")&&f.endsWith(".log"))}catch{}process.stdout.write(JSON.stringify(l))' "$OUT" 2>/dev/null)
|
||||
[ -n "$saved_json" ] || saved_json='[]'
|
||||
sum="rounds $rounds, this slice $slice_programs programs and $slice_vectors units, total $total_programs programs; $panics mismatch(es); next seed base $base"
|
||||
printf '{"state":"ran","summary":%s,"counters":{"programs":%s,"vectors":%s,"panics":%s,"slice_programs":%s,"slice_vectors":%s},"next_seed_base":%s,"out":%s,"saved":%s}' \
|
||||
"$(cap_json_str "$sum")" "$total_programs" "$total_vectors" "$panics" "$slice_programs" "$slice_vectors" "$base" "$(cap_json_str "$OUT")" "${saved_json:-[]}" | cap_summary pow-fuzz
|
||||
cap_say "$sum"
|
||||
[ "$panics" = 0 ]
|
||||
80
infra/build-server/capacity/jobs/sim-sweeps.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
# Capacity job 3: GHOSTDAG and finality simulator sweeps across the adversary grid the Horizon lanes used, seeds walking
|
||||
# 1 to 1,000 from a cursor. Runs sim/horizon/consensus-security/ghostdag_sim.py and finality_horizon.py, sim/finality_v2.py
|
||||
# and the difficulty attack sims (sim/difficulty/attacks/attacks.py). Results appended as CSV under the out dir; a daily
|
||||
# summary notes any bound that moved against the previous day. Pure Python (numpy on the box), no build, no node.
|
||||
# jobs/sim-sweeps.sh --dry-run print the plan and the next seed; run nothing
|
||||
# jobs/sim-sweeps.sh --smoke one seed through each sim, quick mode (~10 min)
|
||||
# jobs/sim-sweeps.sh --slice-s 3600 sweep seeds until about this long elapses
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=sim-sweeps; export CAP_PRIORITY=3
|
||||
. "$HERE/../lib.sh"
|
||||
|
||||
DRY=0; SMOKE=0; SLICE_S="${CAP_SLICE_S:-3600}"; SEEDS_PER="${CAP_SIM_SEEDS:-5}"; QUICK=""
|
||||
while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1;; --smoke) SMOKE=1; SLICE_S=600; SEEDS_PER=1; QUICK="--quick";; --slice-s) SLICE_S="$2"; shift;; *) cap_say "unknown arg $1";; esac; shift; done
|
||||
|
||||
seed=$(cap_cursor_get sim-sweeps 1); [ "$seed" -ge 1 ] 2>/dev/null || seed=1
|
||||
if [ "$DRY" = 1 ]; then
|
||||
cap_say "DRY RUN: would sweep ghostdag_sim, finality_horizon, finality_v2 and difficulty attacks from seed $seed, $SEEDS_PER per slice"
|
||||
printf '{"state":"idle","summary":%s,"counters":{"seeds":0,"rows":0,"moved":0},"dry_run":true,"next_seed":%s}' "$(cap_json_str "dry run: next seed $seed")" "$seed" | cap_summary sim-sweeps
|
||||
exit 0
|
||||
fi
|
||||
cap_sync_checkout >/dev/null 2>&1 || true; [ -d "$CAP_SRC/sim" ] || { cap_say "no checkout"; exit 1; }
|
||||
OUT=$(cap_out_dir sim-sweeps); CSV="$OUT/sweeps.csv"; [ -f "$CSV" ] || echo "utc,sim,seed,metric,value" > "$CSV"
|
||||
cd "$CAP_SRC"
|
||||
PY="python3"
|
||||
deadline=$(( $(date +%s) + SLICE_S )); did=0; rows0=$(wc -l < "$CSV")
|
||||
|
||||
emit() { echo "$(date -u +%H:%M:%S),$1,$2,$3,$4" >> "$CSV"; }
|
||||
|
||||
while [ "$(date +%s)" -lt "$deadline" ] && [ "$seed" -le 1000 ]; do
|
||||
s_end=$((seed + SEEDS_PER - 1)); [ "$s_end" -gt 1000 ] && s_end=1000; n=$((s_end - seed + 1))
|
||||
# 1. GHOSTDAG withholding: --seed-base offsets the episode seeds; capture the worst reorg depth and win rate
|
||||
gd="$OUT/ghostdag-seed$seed.json"
|
||||
if timeout 1200 $PY sim/horizon/consensus-security/ghostdag_sim.py --seeds "$n" --seed-base "$seed" --holds 30,60 --delays 0.67,2 --shares 0.34,0.51 --json "$gd" > "$OUT/ghostdag-seed$seed.md" 2>>"$CAP_LOG/sim.log"; then
|
||||
mx=$($PY -c 'import json,sys;d=json.load(open(sys.argv[1]));print(max((c.get("reorg_max",0) for c in d["cells"]),default=0))' "$gd" 2>/dev/null || echo 0)
|
||||
won=$($PY -c 'import json,sys;d=json.load(open(sys.argv[1]));print(round(max((c.get("won",0) for c in d["cells"]),default=0),3))' "$gd" 2>/dev/null || echo 0)
|
||||
emit ghostdag "$seed-$s_end" reorg_max "$mx"; emit ghostdag "$seed-$s_end" won_max "$won"
|
||||
else emit ghostdag "$seed-$s_end" error timeout_or_fail; fi
|
||||
# 2. finality horizon (v2 and v3 over the share grid): the renter day-10 and day-20 share reaching the veto
|
||||
if timeout 1800 $PY sim/horizon/consensus-security/finality_horizon.py --seeds "$seed" --sweeps R $QUICK --out "$OUT/finality-horizon-seed$seed.md" >>"$CAP_LOG/sim.log" 2>&1; then
|
||||
emit finality_horizon "$seed" ran 1
|
||||
else emit finality_horizon "$seed" error timeout_or_fail; fi
|
||||
# 3. difficulty attacks across the controllers (seed-base walks)
|
||||
if timeout 1200 $PY sim/difficulty/attacks/attacks.py --seeds "$n" --seed-base "$seed" --scenario ts,hop --rules igneum > "$OUT/diff-attacks-seed$seed.md" 2>>"$CAP_LOG/sim.log"; then
|
||||
emit difficulty "$seed-$s_end" ran 1
|
||||
else emit difficulty "$seed-$s_end" error timeout_or_fail; fi
|
||||
did=$((did + n)); seed=$((s_end + 1)); cap_cursor_set sim-sweeps "$seed"
|
||||
[ "$SMOKE" = 1 ] && break
|
||||
done
|
||||
|
||||
# 4. finality_v2 quick once per slice (the full scenario set, not seed-swept): a liveness and lock sanity check
|
||||
timeout 900 $PY sim/finality_v2.py --quick --scenarios A,B,C,D,E,F,G > "$OUT/finality-v2-quick.md" 2>>"$CAP_LOG/sim.log" && emit finality_v2 quick ran 1 || emit finality_v2 quick error timeout_or_fail
|
||||
|
||||
# daily summary: any metric that moved against yesterday's csv
|
||||
prev=$(ls -d "$CAP_OUT_ROOT/sim-sweeps"/*/ 2>/dev/null | grep -v "/$(UTC_DATE)/" | sort | tail -1)
|
||||
moved=0; movelog="$OUT/moved.txt"; : > "$movelog"
|
||||
if [ -n "$prev" ] && [ -f "${prev}sweeps.csv" ]; then
|
||||
moved=$($PY - "$CSV" "${prev}sweeps.csv" <<'PYEOF' 2>/dev/null || echo 0
|
||||
import sys,csv
|
||||
def worst(p):
|
||||
m={}
|
||||
for r in csv.DictReader(open(p)):
|
||||
try:v=float(r["value"])
|
||||
except:continue
|
||||
k=(r["sim"],r["metric"]); m[k]=max(m.get(k,v),v)
|
||||
return m
|
||||
a=worst(sys.argv[1]); b=worst(sys.argv[2]); n=0
|
||||
for k in a:
|
||||
if k in b and abs(a[k]-b[k])>1e-9:
|
||||
print("%s %s: %.3f -> %.3f"%(k[0],k[1],b[k],a[k]),file=sys.stderr); n+=1
|
||||
print(n)
|
||||
PYEOF
|
||||
)
|
||||
ls -d "$CAP_OUT_ROOT/sim-sweeps"/*/ >/dev/null 2>&1 && grep -h . "$movelog" 2>/dev/null || true
|
||||
fi
|
||||
rows=$(( $(wc -l < "$CSV") - rows0 ))
|
||||
sum="swept $did seeds this slice (next $seed/1000), $rows CSV rows added; bounds moved vs yesterday: $moved"
|
||||
printf '{"state":"ran","summary":%s,"counters":{"seeds_swept":%s,"rows":%s,"moved":%s},"next_seed":%s,"out":%s}' \
|
||||
"$(cap_json_str "$sum")" "$did" "$rows" "${moved:-0}" "$seed" "$(cap_json_str "$OUT")" | cap_summary sim-sweeps
|
||||
cap_say "$sum"
|
||||
93
infra/build-server/capacity/jobs/sync-fuzz.sh
Executable file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env bash
|
||||
# Capacity job 2: the sync-request fuzz against a pruned node (the Horizon security lane's gate for the 28 sync-manager
|
||||
# unwrap sites, docs/analysis/horizon/consensus-security.md section 5). Starts a throwaway pruned igneumd on a simnet
|
||||
# datadir on the box (the 0.3.15 node line; NEVER the live devnet), on loopback ports far from every other network, and
|
||||
# drives igneum-p2p-probe sync-fuzz at it: random, boundary and below-retention locator, header, antipast, IBD and
|
||||
# pruning-point requests. The node is the subject; a gRPC alive check every N requests catches any panic, which is saved
|
||||
# with the request. Builds igneumd and igneum-p2p-probe from the capacity fork checkout.
|
||||
# jobs/sync-fuzz.sh --dry-run plan only; also runs the probe's own --dry-run (builds one request of each kind)
|
||||
# jobs/sync-fuzz.sh --smoke build, start a node, 10 minutes of requests, stop the node
|
||||
# jobs/sync-fuzz.sh --slice-s 1800 fuzz for about this long (the controller's slice)
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=sync-fuzz; export CAP_PRIORITY=2
|
||||
. "$HERE/../lib.sh"
|
||||
|
||||
DRY=0; SMOKE=0; SLICE_S="${CAP_SLICE_S:-1800}"
|
||||
while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1;; --smoke) SMOKE=1; SLICE_S=360;; --slice-s) SLICE_S="$2"; shift;; *) cap_say "unknown arg $1";; esac; shift; done
|
||||
|
||||
BASE_PORT="${CAP_SYNC_PORT:-31500}" # grpc BASE+0, p2p BASE+1, json BASE+2: far from live (26610/40), harness (27xxx), fast-time (295xx)
|
||||
SUFFIX="${CAP_SYNC_SUFFIX:-315}"
|
||||
DATADIR="$CAP_ROOT/sync-node"
|
||||
NET="igneum-devnet-$SUFFIX"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
cap_say "DRY RUN: would start a pruned igneumd (net $NET, grpc 127.0.0.1:$BASE_PORT, p2p 127.0.0.1:$((BASE_PORT+1)), data $DATADIR) and fuzz it"
|
||||
PROBE="$CAP_FORK/target-capacity/release/igneum-p2p-probe"
|
||||
if [ -x "$PROBE" ]; then "$PROBE" sync-fuzz 127.0.0.1:$((BASE_PORT+1)) grpc://127.0.0.1:$BASE_PORT --dry-run --seed 1 || true; fi
|
||||
printf '{"state":"idle","summary":%s,"counters":{"requests":0,"panics":0},"dry_run":true}' "$(cap_json_str "dry run: net $NET, loopback only, live devnet untouched")" | cap_summary sync-fuzz
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cap_sync_checkout >/dev/null 2>&1 || true; [ -d "$CAP_FORK/.git" ] || { cap_say "no checkout"; exit 1; }
|
||||
OUT=$(cap_out_dir sync-fuzz)
|
||||
export CARGO_TARGET_DIR="$CAP_FORK/target-capacity"
|
||||
IGNEUMD="$CARGO_TARGET_DIR/release/igneumd"
|
||||
PROBE="$CARGO_TARGET_DIR/release/igneum-p2p-probe"
|
||||
OVERRIDE="$CAP_SRC/infra/fast-time/override-60x.json"
|
||||
|
||||
cap_say "building igneumd and igneum-p2p-probe (fork $CAP_NODE_BRANCH)"
|
||||
if ! cap_cargo "$CAP_FORK" build --release -p kaspad -p igneum-p2p-probe --features kaspad/igneum-pow > "$CAP_LOG/sync-build.log" 2>&1; then
|
||||
cap_say "build FAILED (see $CAP_LOG/sync-build.log)"
|
||||
printf '{"state":"error","summary":%s,"counters":{"panics":0}}' "$(cap_json_str "build failed: $(grep -m1 '^error' "$CAP_LOG/sync-build.log" | cut -c1-120)")" | cap_summary sync-fuzz
|
||||
exit 1
|
||||
fi
|
||||
[ -x "$IGNEUMD" ] || IGNEUMD="$CARGO_TARGET_DIR/release/igneumd"
|
||||
# a merged override with skip_proof_of_work so no miner is needed. Python (not node): the override carries u64::MAX
|
||||
# activation fields (18446744073709551615) that JS numbers round to a float the Rust parser rejects; python keeps the int.
|
||||
MERGED="$CAP_ROOT/sync-override.json"
|
||||
python3 -c 'import json,sys;o=json.load(open(sys.argv[1]));o["skip_proof_of_work"]=True;json.dump(o,open(sys.argv[2],"w"))' "$OVERRIDE" "$MERGED" 2>/dev/null || cp "$OVERRIDE" "$MERGED"
|
||||
|
||||
rm -rf "$DATADIR"; mkdir -p "$DATADIR"
|
||||
cap_say "starting pruned igneumd ($NET) on loopback"
|
||||
nice -n 19 ionice -c3 "$IGNEUMD" --devnet --devnet-suffix="$SUFFIX" --nodnsseed --disable-upnp --nologfiles \
|
||||
--enable-unsynced-mining --utxoindex --unsaferpc --retention-period-days=2 \
|
||||
--appdir="$DATADIR" --rpclisten=127.0.0.1:$BASE_PORT --rpclisten-json=127.0.0.1:$((BASE_PORT+2)) \
|
||||
--listen=127.0.0.1:$((BASE_PORT+1)) --outpeers=0 --override-params-file="$MERGED" --loglevel=info --yes \
|
||||
> "$DATADIR/node.log" 2>&1 &
|
||||
NODE_PID=$!
|
||||
cleanup() { kill -INT "$NODE_PID" 2>/dev/null; for _ in $(seq 1 50); do kill -0 "$NODE_PID" 2>/dev/null || break; sleep 0.1; done; kill -KILL "$NODE_PID" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
# wait for the node's gRPC port to accept a connection (bash /dev/tcp; no log-line guessing)
|
||||
up=0; for _ in $(seq 1 90); do
|
||||
kill -0 "$NODE_PID" 2>/dev/null || break
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$BASE_PORT") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 2
|
||||
if [ "$up" != 1 ]; then
|
||||
cap_say "node did not start (see $DATADIR/node.log)"
|
||||
printf '{"state":"error","summary":%s,"counters":{"panics":0}}' "$(cap_json_str "node failed to start: $(tail -2 "$DATADIR/node.log" | tr '\n' ' ' | cut -c1-120)")" | cap_summary sync-fuzz
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trace="$OUT/trace-$(date -u +%H%M%S).jsonl"
|
||||
cap_say "fuzzing for ${SLICE_S}s (net $NET, trace $trace)"
|
||||
"$PROBE" sync-fuzz 127.0.0.1:$((BASE_PORT+1)) grpc://127.0.0.1:$BASE_PORT --seconds "$SLICE_S" --network "$NET" \
|
||||
--seed "$(( $(date +%s) % 100000 ))" --report-every 200 --alive-every 25 | tee "$trace" | tail -1 > "$OUT/.last" || true
|
||||
last=$(cat "$OUT/.last" 2>/dev/null)
|
||||
reqs=$(echo "$last" | "$NODE_BIN" -e 'try{const o=JSON.parse(require("fs").readFileSync(0,"utf8"));process.stdout.write(String(o.requests||0))}catch{process.stdout.write("0")}' 2>/dev/null || echo 0)
|
||||
alive=$(printf "%s" "$last" | grep -c "\"node_alive\":true" 2>/dev/null); alive=${alive:-0}
|
||||
down=$(grep -c '"sync-fuzz":"node-down"' "$trace" 2>/dev/null); down=${down:-0}
|
||||
|
||||
panics=$(cap_cursor_get sync-fuzz-panics 0)
|
||||
if [ "$down" != 0 ] || { [ "$alive" = 0 ] && echo "$last" | grep -q 'node-down'; }; then
|
||||
panics=$((panics + 1)); cap_cursor_set sync-fuzz-panics "$panics"
|
||||
save="$OUT/node-down-$(date -u +%H%M%S).jsonl"; grep -A2 -B2 'node-down' "$trace" > "$save" 2>/dev/null; cp "$DATADIR/node.log" "$OUT/node-$(date -u +%H%M%S).log"
|
||||
cap_say "NODE WENT DOWN under fuzz: saved $save and the node log"
|
||||
fi
|
||||
total=$(cap_cursor_get sync-fuzz-requests 0); total=$((total + reqs)); cap_cursor_set sync-fuzz-requests "$total"
|
||||
sum="this slice $reqs requests, total $total; node alive $([ "$down" = 0 ] && echo yes || echo NO); $panics panic(s); net $NET (loopback, live devnet untouched)"
|
||||
printf '{"state":"ran","summary":%s,"counters":{"requests":%s,"slice_requests":%s,"panics":%s},"out":%s,"node_alive":%s}' \
|
||||
"$(cap_json_str "$sum")" "$total" "$reqs" "$panics" "$(cap_json_str "$OUT")" "$([ "$down" = 0 ] && echo true || echo false)" | cap_summary sync-fuzz
|
||||
cap_say "$sum"
|
||||
[ "$down" = 0 ]
|
||||
120
infra/build-server/capacity/lib.sh
Executable file
|
|
@ -0,0 +1,120 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared helpers for the capacity layer on igneum-build-1 (infra/build-server/capacity). Sourced by run.sh and every
|
||||
# job in jobs/. Nothing here takes a build slot (the slots are /srv/builds/_locks/build-<k>, owned by remote-run.sh)
|
||||
# and nothing writes under /srv/builds/<worktree>. All work lives under /srv/capacity.
|
||||
#
|
||||
# Paths (overridable by environment for the smoke test and the dry run):
|
||||
# CAP_ROOT /srv/capacity the layer's own tree
|
||||
# CAP_SRC $CAP_ROOT/src a clone of /srv/igneum.git (master), the repo the jobs build and run from
|
||||
# CAP_FORK $CAP_SRC/vendor/igneum-node a clone of /srv/igneum-node.git, the node fork
|
||||
# CAP_OUT $CAP_ROOT/out/<job>/<date> results, one directory per job per UTC day
|
||||
# CAP_STATE $CAP_ROOT/state per-job cursors (the continuous jobs advance their seed base here)
|
||||
# CAP_LOG $CAP_ROOT/log per-job slice logs
|
||||
# LOCKS /srv/builds/_locks read only: the build slots and the measure hold the layer yields to
|
||||
# CAP_JSON /srv/workers/capacity.json the one-line-per-job summary the dashboard reads
|
||||
set -uo pipefail
|
||||
|
||||
CAP_ROOT="${CAP_ROOT:-/srv/capacity}"
|
||||
CAP_SRC="${CAP_SRC:-$CAP_ROOT/src}"
|
||||
CAP_FORK="${CAP_FORK:-$CAP_SRC/vendor/igneum-node}"
|
||||
CAP_OUT_ROOT="${CAP_OUT_ROOT:-$CAP_ROOT/out}"
|
||||
CAP_STATE="${CAP_STATE:-$CAP_ROOT/state}"
|
||||
CAP_LOG="${CAP_LOG:-$CAP_ROOT/log}"
|
||||
LOCKS="${IGNEUM_BUILD_SLOTS_DIR:-/srv/builds/_locks}"
|
||||
BUILDS_ROOT="${IGNEUM_BUILD_ROOT:-/srv/builds}"
|
||||
export IGNEUM_CAPACITY_JSON="${IGNEUM_CAPACITY_JSON:-/srv/workers/capacity.json}"
|
||||
REPO_MIRROR="${CAP_REPO_MIRROR:-/srv/igneum.git}"
|
||||
NODE_MIRROR="${CAP_NODE_MIRROR:-/srv/igneum-node.git}"
|
||||
NODE_BRANCH_DEFAULT="capacity-probe" # the sync-fuzz branch; falls back to the newest release-*-node on the mirror
|
||||
REPO_BRANCH="${CAP_REPO_BRANCH:-master}" # the repo branch the layer builds and runs from; master in production, box-capacity until it merges (install.sh writes a drop-in)
|
||||
HERE_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUMMARY="$HERE_LIB/summary.mjs"
|
||||
NODE_BIN="${NODE_BIN:-/usr/local/bin/node}"
|
||||
UTC_DATE() { date -u +%Y-%m-%d; }
|
||||
cap_say() { printf '%s capacity/%s: %s\n' "$(date -u +%H:%M:%S)" "${CAP_JOB:-run}" "$*" >&2; }
|
||||
# defaults so `set -u` never trips before the first cap_sync_checkout (a standalone job, or a smoke)
|
||||
CAP_NODE_BRANCH="${CAP_NODE_BRANCH:-}"; CAP_REPO_SHA="${CAP_REPO_SHA:-}"; CAP_FORK_SHA="${CAP_FORK_SHA:-}"; CAP_REPO_BRANCH_USED="${CAP_REPO_BRANCH_USED:-}"
|
||||
|
||||
# ---- the build-slot and measure hold the layer yields to --------------------------------------------------------------
|
||||
# a slot or the measure file is "held" when flock -n cannot take it (another process holds the fd). The same probe the
|
||||
# collector uses (tools/workers/collect.mjs flockHeld). Returns 0 (true) when ANY build slot or the measure hold is taken.
|
||||
cap_build_active() {
|
||||
local slots k f
|
||||
slots=$(cat "$LOCKS/slots" 2>/dev/null || echo 1); [ "$slots" -ge 1 ] 2>/dev/null || slots=1
|
||||
if [ -e "$LOCKS/measure" ] && ! flock -n "$LOCKS/measure" true 2>/dev/null; then return 0; fi
|
||||
for k in $(seq 0 $((slots - 1))); do
|
||||
f="$LOCKS/build-$k"
|
||||
[ -e "$f" ] || continue
|
||||
if ! flock -n "$f" true 2>/dev/null; then return 0; fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
cap_hold_reason() {
|
||||
local slots k
|
||||
if [ -e "$LOCKS/measure" ] && ! flock -n "$LOCKS/measure" true 2>/dev/null; then echo "measure hold"; return; fi
|
||||
slots=$(cat "$LOCKS/slots" 2>/dev/null || echo 1); [ "$slots" -ge 1 ] 2>/dev/null || slots=1
|
||||
for k in $(seq 0 $((slots - 1))); do
|
||||
[ -e "$LOCKS/build-$k" ] || continue
|
||||
if ! flock -n "$LOCKS/build-$k" true 2>/dev/null; then echo "build slot build-$k held"; return; fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ---- the capacity checkout (never a /srv/builds worktree) -------------------------------------------------------------
|
||||
# clone-or-fetch the repo at master and the fork at its branch into $CAP_SRC. Idempotent. The jobs build into target
|
||||
# directories under this tree, never /srv/builds.
|
||||
cap_sync_checkout() {
|
||||
local node_branch="${1:-$NODE_BRANCH_DEFAULT}"
|
||||
mkdir -p "$CAP_ROOT" "$CAP_STATE" "$CAP_LOG"
|
||||
if [ ! -d "$CAP_SRC/.git" ]; then git clone -q "$REPO_MIRROR" "$CAP_SRC" || { cap_say "repo clone failed"; return 1; }; fi
|
||||
git -C "$CAP_SRC" fetch -q origin '+refs/heads/*:refs/remotes/origin/*' || cap_say "repo fetch failed (using the last checkout)"
|
||||
git -C "$CAP_SRC" checkout -q -- . 2>/dev/null || true
|
||||
git -C "$CAP_SRC" clean -qfd -e target -e 'target-*' -e vendor -e out 2>/dev/null || true
|
||||
local rb="$REPO_BRANCH"
|
||||
git -C "$CAP_SRC" rev-parse -q --verify "origin/$rb" >/dev/null 2>&1 || rb=master
|
||||
git -C "$CAP_SRC" checkout -q -B capacity-run "origin/$rb" 2>/dev/null || git -C "$CAP_SRC" reset -q --hard "origin/$rb"
|
||||
CAP_REPO_BRANCH_USED="$rb"; export CAP_REPO_BRANCH_USED
|
||||
mkdir -p "$CAP_SRC/vendor"
|
||||
if [ ! -d "$CAP_FORK/.git" ]; then git clone -q --no-checkout "$NODE_MIRROR" "$CAP_FORK" || { cap_say "fork clone failed"; return 1; }; fi
|
||||
git -C "$CAP_FORK" fetch -q origin '+refs/heads/*:refs/remotes/origin/*' || cap_say "fork fetch failed"
|
||||
local b="$node_branch"
|
||||
git -C "$CAP_FORK" rev-parse -q --verify "origin/$b" >/dev/null 2>&1 || b=$(git -C "$CAP_FORK" branch -r --list 'origin/release-*-node' | sed 's|.*/||' | sort -V | tail -1)
|
||||
[ -n "$b" ] || b=master
|
||||
git -C "$CAP_FORK" checkout -q -- . 2>/dev/null || true
|
||||
git -C "$CAP_FORK" clean -qfd -e target -e 'target-*' 2>/dev/null || true
|
||||
git -C "$CAP_FORK" checkout -q -B "$b" "origin/$b" 2>/dev/null || git -C "$CAP_FORK" reset -q --hard "origin/$b"
|
||||
CAP_NODE_BRANCH="$b"
|
||||
CAP_REPO_SHA=$(git -C "$CAP_SRC" rev-parse --short HEAD 2>/dev/null)
|
||||
CAP_FORK_SHA=$(git -C "$CAP_FORK" rev-parse --short HEAD 2>/dev/null)
|
||||
export CAP_NODE_BRANCH CAP_REPO_SHA CAP_FORK_SHA
|
||||
cap_say "checkout: repo master $CAP_REPO_SHA, fork $CAP_NODE_BRANCH $CAP_FORK_SHA"
|
||||
}
|
||||
|
||||
# cargo under the layer's discipline: no build slot, idle IO, SCHED_IDLE, nice 19, a bounded job count (the controller's
|
||||
# SIGSTOP pauses it the instant a real build takes a slot; this keeps it gentle even while it runs).
|
||||
cap_cargo() {
|
||||
( cd "$1" && shift && CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS="${CAP_CARGO_JOBS:-16}" \
|
||||
nice -n 19 ionice -c3 chrt -i 0 "$HOME/.cargo/bin/cargo" "$@" )
|
||||
}
|
||||
# cap_cargo with a timeout: <secs> <dir> <cargo args...>. `timeout` cannot run the cap_cargo shell function, so the
|
||||
# timeout wraps the external cargo directly (same nice/ionice/chrt). Returns 124 on timeout.
|
||||
cap_cargo_t() {
|
||||
local secs="$1" dir="$2"; shift 2
|
||||
( cd "$dir" && CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS="${CAP_CARGO_JOBS:-16}" \
|
||||
timeout "$secs" nice -n 19 ionice -c3 chrt -i 0 "$HOME/.cargo/bin/cargo" "$@" )
|
||||
}
|
||||
|
||||
cap_out_dir() { # <job>: make and echo today's out dir
|
||||
local d="$CAP_OUT_ROOT/$1/$(UTC_DATE)"; mkdir -p "$d"; echo "$d"
|
||||
}
|
||||
|
||||
# cap_summary <job> : read a JSON fragment on stdin and merge it into capacity.json under that job
|
||||
cap_summary() { CAP_PRIORITY="${CAP_PRIORITY:-}" "$NODE_BIN" "$SUMMARY" job "$1"; }
|
||||
cap_controller_summary() { "$NODE_BIN" "$SUMMARY" controller; }
|
||||
|
||||
# a cursor is a plain number per job (the continuous jobs' seed base); cap_cursor_get / cap_cursor_set
|
||||
cap_cursor_get() { cat "$CAP_STATE/$1.cursor" 2>/dev/null || echo "${2:-0}"; }
|
||||
cap_cursor_set() { mkdir -p "$CAP_STATE"; echo "$2" > "$CAP_STATE/$1.cursor"; }
|
||||
|
||||
# json_escape a string for a one-line summary (python, always present)
|
||||
cap_json_str() { "$NODE_BIN" -e 'process.stdout.write(JSON.stringify(process.argv[1]||""))' "$1"; }
|
||||
84
infra/build-server/capacity/run.sh
Executable file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env bash
|
||||
# The capacity controller on igneum-build-1 (infra/build-server/capacity). Runs the job queue in priority order, one job
|
||||
# at a time, each for a bounded slice; a 5 s poll of /srv/builds/_locks SIGSTOPs the running job the instant any build
|
||||
# slot or the measure hold is taken and SIGCONTs it when they clear. The layer never takes a build slot and never writes
|
||||
# under /srv/builds/<worktree>. Started as user build by igneum-capacity.service (Nice 19, chrt -i 0 wrapper, CPUQuota
|
||||
# leaving 8 threads free). Killed by the night battery's start and restarted after (the service's ExecStartPre/StopPost).
|
||||
#
|
||||
# run.sh the controller loop (systemd runs this)
|
||||
# run.sh --once one pass through the weighted sequence, then exit (for a manual check)
|
||||
# run.sh --dry-run call every job's --dry-run in priority order and exit
|
||||
#
|
||||
# The weighted sequence (CAP_SEQUENCE) gives the earlier, higher-priority jobs more turns. Default:
|
||||
# pow-fuzz pow-fuzz pow-fuzz sync-fuzz sync-fuzz sim-sweeps model-sweeps clippy-audit
|
||||
# Each turn runs one job for CAP_SLICE_S (default 1800 s) through run_slice, which backgrounds the job in its own
|
||||
# process group and pauses/resumes it with the lock poll.
|
||||
set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; export CAP_JOB=run
|
||||
. "$HERE/../capacity/lib.sh" 2>/dev/null || . "$HERE/lib.sh"
|
||||
|
||||
SLICE_S="${CAP_SLICE_S:-1800}"
|
||||
POLL_S="${CAP_POLL_S:-5}"
|
||||
SEQUENCE="${CAP_SEQUENCE:-pow-fuzz pow-fuzz pow-fuzz sync-fuzz sync-fuzz sim-sweeps model-sweeps clippy-audit}"
|
||||
JOBS_DIR="$HERE/jobs"
|
||||
|
||||
"$NODE_BIN" "$SUMMARY" init 2>/dev/null || true
|
||||
|
||||
if [ "${1:-}" = --dry-run ]; then
|
||||
for j in pow-fuzz sync-fuzz sim-sweeps model-sweeps clippy-audit; do
|
||||
cap_say "dry-run $j"; bash "$JOBS_DIR/$j.sh" --dry-run || cap_say "$j dry-run returned $?"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# run_slice <job>: background the job in its own process group, poll the locks, STOP while a build or measure holds,
|
||||
# CONT when clear, enforce the slice as a wall clock, reap at the end. The job writes its own summary; the controller
|
||||
# updates the top-level state and current_job.
|
||||
run_slice() {
|
||||
local job="$1" log="$CAP_LOG/$job.slice.log"; mkdir -p "$CAP_LOG"
|
||||
cap_say "slice start: $job (${SLICE_S}s)"
|
||||
# own process group via setsid so STOP/CONT reach the whole cargo/python tree
|
||||
setsid bash "$JOBS_DIR/$job.sh" --slice-s "$SLICE_S" > "$log" 2>&1 &
|
||||
local pid=$! pgid; pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' '); [ -n "$pgid" ] || pgid="$pid"
|
||||
local paused=0 end=$(( $(date +%s) + SLICE_S + 120 )) # a 2 min grace over the slice for the job's own teardown
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
if cap_build_active; then
|
||||
if [ "$paused" = 0 ]; then kill -STOP -"$pgid" 2>/dev/null && paused=1; local why; why=$(cap_hold_reason); cap_say "pause $job ($why)"; printf '{"state":"paused","current_job":%s,"paused_reason":%s,"slice_s":%s}' "$(cap_json_str "$job")" "$(cap_json_str "$why")" "$SLICE_S" | cap_controller_summary; fi
|
||||
else
|
||||
if [ "$paused" = 1 ]; then kill -CONT -"$pgid" 2>/dev/null; paused=0; cap_say "resume $job"; printf '{"state":"running","current_job":%s,"paused_reason":null,"slice_s":%s}' "$(cap_json_str "$job")" "$SLICE_S" | cap_controller_summary; fi
|
||||
fi
|
||||
# a safety stop so a wedged job cannot hold the turn forever; a paused job's clock does not advance it past end+grace
|
||||
if [ "$paused" = 0 ] && [ "$(date +%s)" -ge "$end" ]; then cap_say "slice over time, stopping $job"; kill -INT -"$pgid" 2>/dev/null; sleep 3; kill -KILL -"$pgid" 2>/dev/null; break; fi
|
||||
sleep "$POLL_S"
|
||||
done
|
||||
[ "$paused" = 1 ] && kill -CONT -"$pgid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null; local rc=$?
|
||||
cap_say "slice end: $job rc $rc"
|
||||
}
|
||||
|
||||
once() {
|
||||
for job in $SEQUENCE; do
|
||||
[ -f "$JOBS_DIR/$job.sh" ] || { cap_say "no job $job"; continue; }
|
||||
# wait out a running build before starting a slice (do not even launch during a build)
|
||||
while cap_build_active; do
|
||||
printf '{"state":"waiting","current_job":null,"paused_reason":%s,"slice_s":%s}' "$(cap_json_str "$(cap_hold_reason)")" "$SLICE_S" | cap_controller_summary
|
||||
sleep "$POLL_S"
|
||||
done
|
||||
printf '{"state":"running","current_job":%s,"paused_reason":null,"slice_s":%s,"host":%s}' "$(cap_json_str "$job")" "$SLICE_S" "$(cap_json_str "$(hostname)")" | cap_controller_summary
|
||||
run_slice "$job"
|
||||
done
|
||||
}
|
||||
|
||||
cap_sync_checkout >/dev/null 2>&1 || cap_say "initial checkout had trouble (jobs will retry)"
|
||||
if [ "${1:-}" = --once ]; then once; printf '{"state":"idle","current_job":null,"paused_reason":null}' | cap_controller_summary; exit 0; fi
|
||||
|
||||
trap 'cap_say "controller stopping"; printf "{\"state\":\"stopped\",\"current_job\":null}" | cap_controller_summary; exit 0' INT TERM
|
||||
cap_say "controller start (sequence: $SEQUENCE; slice ${SLICE_S}s; poll ${POLL_S}s)"
|
||||
cycle=0
|
||||
while true; do
|
||||
cycle=$((cycle + 1))
|
||||
# refresh the checkout at the top of each cycle (cheap when nothing moved); yields if a build is active
|
||||
while cap_build_active; do sleep "$POLL_S"; done
|
||||
cap_sync_checkout >/dev/null 2>&1 || true
|
||||
once
|
||||
done
|
||||
72
infra/build-server/capacity/summary.mjs
Executable file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env node
|
||||
// The capacity layer's summary writer. Atomically merges one JSON fragment into /srv/workers/capacity.json, the file
|
||||
// the worker dashboard's collector (tools/workers/collect.mjs) reads for the "Background" lane. Never takes a build
|
||||
// slot, never writes anywhere but IGNEUM_CAPACITY_JSON. Node 22, no deps.
|
||||
//
|
||||
// echo '{"state":"running","summary":"...","counters":{...}}' | summary.mjs job pow-fuzz
|
||||
// merge the fragment into doc.jobs["pow-fuzz"], stamping updated_at; set doc.jobs[job].priority from CAP_PRIORITY if given
|
||||
// echo '{"state":"running","current_job":"pow-fuzz","paused_reason":null}' | summary.mjs controller
|
||||
// merge the fragment into the top-level controller fields (state, current_job, paused_reason, slice, host)
|
||||
// summary.mjs init write an empty document if none exists
|
||||
//
|
||||
// The document:
|
||||
// { v, generated_at, host, state, current_job, paused_reason, slice_s,
|
||||
// jobs: { <job>: { priority, state, updated_at, summary, counters, panics, saved, ... } } }
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { hostname } from 'node:os';
|
||||
|
||||
const OUT = process.env.IGNEUM_CAPACITY_JSON || '/srv/workers/capacity.json';
|
||||
const iso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const d = JSON.parse(readFileSync(OUT, 'utf8'));
|
||||
if (d && typeof d === 'object') { d.jobs = d.jobs || {}; return d; }
|
||||
} catch { /* fall through to a fresh document */ }
|
||||
return { v: 1, host: hostname(), state: 'starting', current_job: null, paused_reason: null, slice_s: null, jobs: {} };
|
||||
}
|
||||
|
||||
function save(d) {
|
||||
d.v = 1; d.host = d.host || hostname(); d.generated_at = iso();
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
const tmp = OUT + '.tmp';
|
||||
writeFileSync(tmp, JSON.stringify(d));
|
||||
renameSync(tmp, OUT);
|
||||
}
|
||||
|
||||
function readStdin() {
|
||||
try {
|
||||
const t = readFileSync(0, 'utf8').trim();
|
||||
if (!t) return {};
|
||||
const o = JSON.parse(t);
|
||||
return o && typeof o === 'object' ? o : {};
|
||||
} catch (e) { process.stderr.write('summary.mjs: bad JSON on stdin: ' + (e.message || e) + '\n'); process.exit(1); }
|
||||
}
|
||||
|
||||
const mode = process.argv[2];
|
||||
const d = load();
|
||||
|
||||
if (mode === 'init') { save(d); process.exit(0); }
|
||||
|
||||
if (mode === 'controller') {
|
||||
const frag = readStdin();
|
||||
for (const k of ['state', 'current_job', 'paused_reason', 'slice_s', 'host']) if (k in frag) d[k] = frag[k];
|
||||
save(d);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === 'job') {
|
||||
const job = process.argv[3];
|
||||
if (!job) { process.stderr.write('summary.mjs job <name>\n'); process.exit(1); }
|
||||
const frag = readStdin();
|
||||
const prev = d.jobs[job] || {};
|
||||
const merged = { ...prev, ...frag, updated_at: iso() };
|
||||
if (process.env.CAP_PRIORITY) merged.priority = Number(process.env.CAP_PRIORITY);
|
||||
d.jobs[job] = merged;
|
||||
save(d);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stderr.write('summary.mjs: mode is one of init | controller | job <name>\n');
|
||||
process.exit(1);
|
||||
30
infra/build-server/ci-red/igneum-ci-red.service
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# The red-master watcher's poster on igneum-build-1: one pass a minute from igneum-ci-red.timer.
|
||||
# The workflow's `red` job (ci.yml, windows.yml; runs on this box's runner after a failed master or release-* run) appends
|
||||
# one JSON line per run to /srv/ci-red/red.jsonl as the runner user. This pass, as build, posts every line not yet posted
|
||||
# to the hidden updates channel (DISCORD_WEBHOOK_UPDATES in /srv/discord-hooks/env) and records the run id in
|
||||
# /srv/discord-hooks/ci-red-posted.json. One line per run, however many passes. Box rows (remote-run.sh, "source":"box")
|
||||
# are never posted alone; the first pass at or after 09:00 London posts the day's digest (counts per class with each class's
|
||||
# guard). `journalctl -u igneum-ci-red -n 30`.
|
||||
# Installed by infra/build-server/ci-red/install.sh.
|
||||
[Unit]
|
||||
Description=Igneum CI red watcher (post failed master and release runs to the updates channel)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=build
|
||||
Group=build
|
||||
Environment=HOME=/home/build
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
Environment=IGNEUM_DISCORD_ENV=/srv/discord-hooks/env
|
||||
Environment=IGNEUM_CI_RED_STATE=/srv/discord-hooks/ci-red-posted.json
|
||||
WorkingDirectory=/srv/discord-hooks
|
||||
ExecStart=/usr/local/bin/node /srv/discord-hooks/bin/red-watch.mjs tick --file /srv/ci-red/red.jsonl --live
|
||||
TimeoutStartSec=50
|
||||
Nice=10
|
||||
# the unit reads one secret file; nothing else on the box may
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/srv/discord-hooks
|
||||
13
infra/build-server/ci-red/igneum-ci-red.timer
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Fires the CI red watcher's poster every minute. `systemctl list-timers igneum-ci-red.timer` shows the next pass.
|
||||
[Unit]
|
||||
Description=Igneum CI red watcher, a pass every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=60s
|
||||
OnCalendar=*-*-* *:*:30
|
||||
AccuracySec=5s
|
||||
Persistent=true
|
||||
Unit=igneum-ci-red.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
26
infra/build-server/ci-red/install.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install or refresh the CI red watcher's poster on igneum-build-1 from this Mac.
|
||||
# infra/build-server/ci-red/install.sh (re-run whenever tools/ci/red-watch.mjs or the units change)
|
||||
# Copies tools/ci/red-watch.mjs to /srv/discord-hooks/bin (beside the Discord scheduler, which already holds the webhook
|
||||
# file), creates /srv/ci-red with the shared group `cired` (runner and build; the workflow's `red` job appends CI rows as
|
||||
# runner, remote-run.sh appends box rows as build, the file is 664 in a 2775 directory), installs the two units and enables
|
||||
# the timer (one `tick` a minute: post new CI rows, then the 09:00 London digest). No secret moves here: the poster
|
||||
# reads the webhook file the Discord scheduler's install.sh already placed (DISCORD_WEBHOOK_UPDATES is the key it needs;
|
||||
# until that key is in ~/.config/igneum/discord and that install.sh is re-run, every pass logs the missing key by name
|
||||
# and the lines wait in red.jsonl). Needs root over ssh (root@<ip> with ~/.ssh/igneum_ed25519); the host ip comes from
|
||||
# ~/.config/igneum/build-server (build@<ip>).
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/../../.." && pwd)"
|
||||
KEY="${IGNEUM_BUILD_KEY:-$HOME/.ssh/igneum_ed25519}"
|
||||
HOST_LINE="$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" | tr -d '[:space:]')"
|
||||
IP="${HOST_LINE#*@}"; [ -n "$IP" ] || { echo "no build server in ~/.config/igneum/build-server" >&2; exit 1; }
|
||||
node "$ROOT/tools/ci/red-watch.mjs" --self-test >/dev/null || { echo "red-watch.mjs fails its own self-test; not installing" >&2; exit 1; }
|
||||
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10)
|
||||
"${SSH[@]}" "root@$IP" 'install -d -o build -g build -m 750 /srv/discord-hooks /srv/discord-hooks/bin; getent group cired >/dev/null || groupadd cired; usermod -aG cired runner; usermod -aG cired build; install -d -o root -g cired -m 2775 /srv/ci-red; [ -f /srv/ci-red/red.jsonl ] || install -o root -g cired -m 664 /dev/null /srv/ci-red/red.jsonl; chgrp cired /srv/ci-red/red.jsonl; chmod 664 /srv/ci-red/red.jsonl'
|
||||
scp -q -i "$KEY" "$ROOT/tools/ci/red-watch.mjs" "build@$IP:/srv/discord-hooks/bin/"
|
||||
scp -q -i "$KEY" "$HERE/igneum-ci-red.service" "$HERE/igneum-ci-red.timer" "root@$IP:/etc/systemd/system/"
|
||||
"${SSH[@]}" "root@$IP" 'systemctl daemon-reload && systemctl enable --now igneum-ci-red.timer >/dev/null 2>&1; systemctl is-active igneum-ci-red.timer; systemctl list-timers igneum-ci-red.timer --no-pager | sed -n 2p'
|
||||
# one dry pass as the unit's user: what would be posted, names only, never a URL
|
||||
"${SSH[@]}" "build@$IP" 'IGNEUM_DISCORD_ENV=/srv/discord-hooks/env IGNEUM_CI_RED_STATE=/srv/discord-hooks/ci-red-posted.json /usr/local/bin/node /srv/discord-hooks/bin/red-watch.mjs post --file /srv/ci-red/red.jsonl'
|
||||
echo "installed; the poster runs at :30 every minute: journalctl -u igneum-ci-red -n 20; the record file: ssh build@$IP cat /srv/ci-red/red.jsonl"
|
||||
|
|
@ -25,6 +25,11 @@ Environment=BR_AGENT=night
|
|||
Environment="BR_COMMAND=night-battery.sh (suites, fuzz, sims, harness, clippy, audit)"
|
||||
Environment=BR_TARGET=x86_64-unknown-linux-gnu
|
||||
Environment=IGNEUM_AGENT=night
|
||||
# the background capacity layer is killed while the battery runs and started again after (capacity rule, section 8 of
|
||||
# docs/plans/build-server.md); the + prefix runs these as root regardless of User=build. A failed stop/start never
|
||||
# fails the battery (-).
|
||||
ExecStartPre=+-/usr/bin/systemctl stop igneum-capacity.service
|
||||
ExecStopPost=+-/usr/bin/systemctl start igneum-capacity.service
|
||||
ExecStart=/bin/bash /srv/builds/_bin/remote-run.sh
|
||||
TimeoutStartSec=8h
|
||||
StandardOutput=append:/srv/builds/_log/night-battery.log
|
||||
|
|
|
|||
|
|
@ -482,6 +482,15 @@ step_runner() {
|
|||
done
|
||||
as_runner "$rustup component list --installed --toolchain $RUST_TOOLCHAIN" | grep -q '^clippy' || { as_runner "$rustup component add clippy rustfmt --toolchain $RUST_TOOLCHAIN" >/dev/null; any=1; }
|
||||
as_runner "git config --global --get safe.directory >/dev/null 2>&1 || git config --global --add safe.directory '*'" # the mirrors are owned by build
|
||||
# 3b. the CI red watcher's record file: the workflow's `red` job appends one line per failed master or release run here
|
||||
# (tools/ci/red-watch.mjs record); the poster (infra/build-server/ci-red) reads it as build
|
||||
# (tools/ci/red-watch.mjs record); remote-run.sh appends the box's own red rows as build; the shared group `cired` lets
|
||||
# both write one file (664 in a 2775 directory), and the poster (infra/build-server/ci-red) reads it as build
|
||||
getent group cired >/dev/null || { groupadd cired; any=1; }
|
||||
id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx cired || { usermod -aG cired "$RUNNER_USER"; any=1; }
|
||||
id -nG "$BUILD_USER" | tr ' ' '\n' | grep -qx cired || { usermod -aG cired "$BUILD_USER"; any=1; }
|
||||
if [ ! -d /srv/ci-red ]; then install -d -o root -g cired -m 2775 /srv/ci-red; any=1; fi
|
||||
[ -f /srv/ci-red/red.jsonl ] || { install -o root -g cired -m 664 /dev/null /srv/ci-red/red.jsonl; any=1; }
|
||||
# 4. sccache: the build user's binary copied system-wide (the runner cannot read /home/build), a read-only view of /srv/sccache
|
||||
if [ ! -x /usr/local/bin/sccache ] || ! cmp -s "$BUILD_HOME/.cargo/bin/sccache" /usr/local/bin/sccache; then
|
||||
install -m 755 "$BUILD_HOME/.cargo/bin/sccache" /usr/local/bin/sccache; any=1
|
||||
|
|
|
|||
|
|
@ -23,7 +23,19 @@
|
|||
# 3. Appends one JSON line to /srv/builds/_log/builds.jsonl (the worker dashboard reads it; asked for by main on 6 October
|
||||
# 2026): on success, on failure and on the slot give-up. UTC ISO 8601 Z times, numbers unquoted, unknown fields omitted,
|
||||
# artefacts with bytes and sha256 only when the run succeeded (a failed build would list the previous build's files),
|
||||
# the line kept under 4 KB.
|
||||
# the line kept under 4 KB. Since the evening of 6 October 2026 (the project lead: "make sure we are fixing and learning from all the
|
||||
# errors here") the line also carries "class" and "run_log":
|
||||
# - PRE-FLIGHT before cargo runs: the manifest must parse (cargo metadata --no-deps) and every `-p` package must exist
|
||||
# (a workspace member, else cargo pkgid --offline); a refusal is exit 3, class preflight-manifest or preflight-package,
|
||||
# and costs a second instead of a slot. The instant-death class: three suite runs on 6 October died in 0 s with exit
|
||||
# 101 and no compile, and nothing on the box had kept the reason.
|
||||
# - the run's output is kept: the last 400 lines in /srv/builds/_log/runs/<id>.log, so a red row can be read after the
|
||||
# agent's terminal is gone.
|
||||
# - CLASS of a red run from that output: instant (under 3 s, nothing compiled), compile-error (error[E...] or "could not
|
||||
# compile"), link-error, test-failure ("test result: FAILED"), slot-timeout (75), no-dir (2), other.
|
||||
# - a `cargo test` with a filter that ran 0 tests everywhere is a wasted round trip: exit 3, class no-test-matched.
|
||||
# - every red row is also appended to the shared red-run file (/srv/ci-red/red.jsonl, $IGNEUM_CI_RED_FILE), the file
|
||||
# tools/ci/red-watch.mjs posts from; box rows are not posted one by one, the 09:00 UK digest counts them per class.
|
||||
# Modes (BR_MODE, default run): `checkout` resets the box's tree and checks the branch out at the commit (lib.sh
|
||||
# bs_push_and_checkout: BR_CO_DIR, BR_CO_MIRROR, BR_CO_BRANCH, BR_CO_SHA, BR_CO_WT); `--self-test` (first argument) builds a
|
||||
# scratch mirror and clone, dirties the clone the way a build's overlay does, moves the mirror one commit on, and shows the
|
||||
|
|
@ -47,8 +59,14 @@ checkout_tree() {
|
|||
[ -n "$wt" ] && mkdir -p "$wt"
|
||||
if [ ! -d "$dir/.git" ]; then rm -rf "$dir"; git clone -q --no-checkout "$mirror" "$dir"; fi
|
||||
cd "$dir"
|
||||
# a run killed mid-git (two runs on one worktree, 18:48:56Z the same day) leaves .git/index.lock; stale when no git runs here
|
||||
if [ -f .git/index.lock ] && ! pgrep -x git >/dev/null 2>&1; then rm -f .git/index.lock; echo "checkout: removed a stale .git/index.lock in $dir" >&2; fi
|
||||
# a run killed mid-git (two runs on one worktree, 18:48:56Z the same day) leaves .git/index.lock; stale when it is older
|
||||
# than 30 s (an index write takes milliseconds, a checkout of the fork seconds). The first version asked `pgrep -x git`,
|
||||
# which said "in use" whenever ANY git ran on the machine: the pre-push hook's own `git push` and any other agent's build
|
||||
# kept the lock and the checkout died with "index.lock: File exists" (6 October 2026, 22:2x UK; the fact is the lock's age)
|
||||
if [ -f .git/index.lock ]; then
|
||||
lock_age=$(( $(date +%s) - $(stat -c %Y .git/index.lock 2>/dev/null || stat -f %m .git/index.lock) ))
|
||||
if [ "$lock_age" -gt 30 ]; then rm -f .git/index.lock; echo "checkout: removed a stale .git/index.lock (${lock_age}s old) in $dir" >&2; fi
|
||||
fi
|
||||
git checkout -q -- . 2>/dev/null || true
|
||||
git clean -qfd -e target -e 'target-*' -e '.build-remote-sha-*' -e '.cross-remote-sha-*' -e sccache
|
||||
git fetch -q origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
|
|
@ -77,7 +95,9 @@ if [ "${1:-}" = --self-test ]; then
|
|||
echo edited > "$t/box/a.txt"; echo new > "$t/box/b.txt"; mkdir -p "$t/box/target/release"; echo bin > "$t/box/target/release/x"; echo "$sha1" > "$t/box/.build-remote-sha-target"
|
||||
# a crate in a subdirectory: its stamp and target dir must survive, its untracked overlay file must not
|
||||
mkdir -p "$t/box/sub/target/release"; echo bin > "$t/box/sub/target/release/y"; echo "$sha1" > "$t/box/sub/.build-remote-sha-target"; echo new > "$t/box/sub/c.txt"
|
||||
: > "$t/box/.git/index.lock" # a run killed mid-git leaves this; the checkout mode removes it when no git runs here
|
||||
: > "$t/box/.git/index.lock" # a run killed mid-git leaves this; the checkout mode removes it once it is older than 30 s
|
||||
touch -t "$(date -d '-2 min' +%Y%m%d%H%M.%S 2>/dev/null || date -v-2M +%Y%m%d%H%M.%S)" "$t/box/.git/index.lock" # backdated two minutes (GNU date, then BSD date)
|
||||
[ $(( $(date +%s) - $(stat -c %Y "$t/box/.git/index.lock" 2>/dev/null || stat -f %m "$t/box/.git/index.lock") )) -gt 30 ] || { echo "self-test: could not backdate the fixture lock"; exit 1; }
|
||||
# the Mac moves on: a new commit that changes a.txt
|
||||
echo a2 > "$t/src/a.txt"; git -C "$t/src" -c user.name=t -c user.email=t@t commit -qam three; git -C "$t/src" push -q "$t/mirror.git" master
|
||||
sha2=$(git -C "$t/src" rev-parse HEAD)
|
||||
|
|
@ -133,14 +153,29 @@ if [ "${1:-}" = --self-test-slots ]; then
|
|||
echo "self-test-slots: two concurrent builds 45 each, a lone build 90, a measure blocks a build, a build blocks a measure, a probe keeps the holder line, the log carries jobs and measure"; exit 0
|
||||
fi
|
||||
|
||||
# One run per worktree directory at a time (6 October 2026, 19:51:09 UK: two runs of one worktree started in the same second;
|
||||
# one found no crate directory while the other's checkout was replacing the tree, exit 2). The checkout and the run each take
|
||||
# the worktree's lock (append mode, held to exit) and wait up to 2 h for it instead of dying; the wait is said on stderr.
|
||||
wt_lock() { # <worktree name>
|
||||
local name="${1//\//_}" fd
|
||||
[ -n "$name" ] || return 0
|
||||
mkdir -p "$IGNEUM_BUILD_SLOTS_DIR" 2>/dev/null || return 0
|
||||
exec {fd}>>"$IGNEUM_BUILD_SLOTS_DIR/wt-$name.lock" || return 0
|
||||
if ! flock -n "$fd"; then
|
||||
echo "build-remote: another run holds worktree $1 on this box, waiting for it (up to 2 h)" >&2
|
||||
flock -w 7200 "$fd" || { echo "build-remote: gave up waiting for worktree $1 after 2 h" >&2; exit 75; }
|
||||
fi
|
||||
}
|
||||
if [ "${BR_MODE:-run}" = checkout ]; then
|
||||
: "${BR_CO_DIR:?}" "${BR_CO_MIRROR:?}" "${BR_CO_BRANCH:?}" "${BR_CO_SHA:?}"
|
||||
wt_lock "${BR_CO_WT:-$(basename "$BR_CO_DIR")}"
|
||||
checkout_tree "$BR_CO_DIR" "$BR_CO_MIRROR" "$BR_CO_BRANCH" "$BR_CO_SHA" "${BR_CO_WT:-}"; exit $?
|
||||
fi
|
||||
|
||||
: "${BR_DIR:?}" "${BR_CMD:?}" "${BR_LABEL:?}" "${BR_TOOL:?}" "${BR_KIND:?}"
|
||||
BR_HOST=$(hostname); BR_PID=$$; BR_T0=$(date +%s)
|
||||
export BR_HOST BR_PID BR_T0
|
||||
wt_lock "${BR_WT:-}"
|
||||
LOG_DIR="${IGNEUM_BUILD_LOG_DIR:-/srv/builds/_log}"; mkdir -p "$LOG_DIR"
|
||||
|
||||
# jsonlog <exit> <slot> <wait_s> <start> <end> <secs> <compiles> <hits> <misses> <hits_total> <misses_total>
|
||||
|
|
@ -162,6 +197,7 @@ d = {
|
|||
"start": iso(e['BR_START']), "end": iso(e['BR_END']), "secs": num(e['BR_SECS']), "exit": num(e['BR_EXIT']),
|
||||
"compiles": num(e['BR_COMPILES']), "jobs": num(e.get('BR_JOBS')), "measure": (e.get('BR_MEASURE') == '1') or None,
|
||||
"source_date_epoch": num(e.get('BR_SDE')),
|
||||
"class": e.get('BR_CLASS') or None, "run_log": e.get('BR_RUN_LOG') or None,
|
||||
}
|
||||
sc = {k: num(e[v]) for k, v in (("hits", "BR_HITS"), ("misses", "BR_MISSES"), ("hits_total", "BR_HITS_T"), ("misses_total", "BR_MISSES_T"))}
|
||||
sc = {k: v for k, v in sc.items() if v is not None}
|
||||
|
|
@ -192,13 +228,34 @@ with open(e['BR_LOG'], 'a') as f:
|
|||
PY
|
||||
}
|
||||
|
||||
# redlog <exit> <secs> <class>: one line in the shape tools/ci/red-watch.mjs reads (source "box"; the digest counts it)
|
||||
redlog() {
|
||||
local f="${IGNEUM_CI_RED_FILE:-/srv/ci-red/red.jsonl}"
|
||||
[ -w "$f" ] || [ -w "$(dirname "$f")" ] || { echo "build-remote: note: $f is not writable, the red row is in builds.jsonl only" >&2; return 0; }
|
||||
BR_EXIT="$1" BR_SECS="$2" BR_CLASS="$3" BR_RED="$f" python3 - <<'PY' || echo "build-remote: WARNING the red-run line was not written" >&2
|
||||
import json, os, time
|
||||
e = os.environ
|
||||
line = {
|
||||
"source": "box", "run_id": f"{e['BR_HOST']}-{e['BR_T0']}-{e['BR_PID']}", "attempt": 1, "workflow": f"box:{e['BR_KIND']}",
|
||||
"branch": e.get('BR_BRANCH') or '', "sha": (e.get('BR_SHA') or '')[:7], "event": e.get('BR_TOOL') or '',
|
||||
"url": "", "at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), "title": (e.get('BR_COMMAND') or '')[:100],
|
||||
"failed": [{"job": f"{e.get('BR_WT') or ''}/{e.get('BR_CRATE') or ''}", "conclusion": "failure",
|
||||
"step": f"{e['BR_CLASS']}: exit {e['BR_EXIT']} after {e['BR_SECS'] or 0} s"}],
|
||||
"class": e['BR_CLASS'], "agent": e.get('BR_AGENT') or '', "run_log": e.get('BR_RUN_LOG') or '', "note": "",
|
||||
}
|
||||
with open(e['BR_RED'], 'a') as f:
|
||||
f.write(json.dumps(line, separators=(',', ':')) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
SLOTS_DIR="$IGNEUM_BUILD_SLOTS_DIR"
|
||||
slots=$(cat "$SLOTS_DIR/slots" 2>/dev/null || echo 1); [ "$slots" -ge 1 ] 2>/dev/null || slots=1
|
||||
JOBS_ALONE="${JOBS_ALONE:-90}"; JOBS_SHARED="${JOBS_SHARED:-45}"
|
||||
holder_line() { printf 'pid %s since %sZ waited %s s: %s\n' "$BR_PID" "$(date -u +%H:%M:%S)" "$1" "$BR_LABEL"; }
|
||||
give_up() { # <what>
|
||||
echo "build-remote: gave up waiting for $1 after 2 h" >&2
|
||||
jsonlog 75 0 $(( $(date +%s) - BR_T0 )) "" "$(date +%s)" "" "" "" "" "" ""
|
||||
BR_CLASS=slot-timeout jsonlog 75 0 $(( $(date +%s) - BR_T0 )) "" "$(date +%s)" "" "" "" "" "" ""
|
||||
redlog 75 $(( $(date +%s) - BR_T0 )) slot-timeout
|
||||
rm -f "$waitfile"; exit 75
|
||||
}
|
||||
waitfile="$SLOTS_DIR/wait-$BR_PID"
|
||||
|
|
@ -255,7 +312,43 @@ else
|
|||
echo "build-remote: holding build-$got on $BR_HOST (waited $waited s; $held of $slots slots held, CARGO_BUILD_JOBS=$BR_JOBS)" >&2
|
||||
fi
|
||||
|
||||
cd "$BR_DIR" || { jsonlog 2 "$got" "$waited" "" "$(date +%s)" "" "" "" "" "" ""; exit 2; }
|
||||
release_slot() { if [ "$got" = measure ]; then : > "$SLOTS_DIR/measure"; else : > "$SLOTS_DIR/build-$got"; fi; }
|
||||
cd "$BR_DIR" || { BR_CLASS=no-dir jsonlog 2 "$got" "$waited" "" "$(date +%s)" "" "" "" "" "" ""; redlog 2 0 no-dir; release_slot; exit 2; }
|
||||
|
||||
# PRE-FLIGHT for a cargo command (the instant-death class, 6 October 2026): the manifest parses and every -p package exists,
|
||||
# answered in about a second from the workspace metadata (no network), before any compile time is spent
|
||||
if [[ "$BR_CMD" =~ ^cargo[[:space:]] ]]; then
|
||||
pf_class=""; pf_msg=""
|
||||
sub=$(printf '%s\n' "$BR_CMD" | awk '{print $2}')
|
||||
if ! cargo --list 2>/dev/null | awk 'NR>1 {print $1}' | grep -qx -- "$sub"; then
|
||||
pf_class=preflight-subcommand; pf_msg="cargo has no '$sub' subcommand on this box (cargo audit at 21:17 UK on 6 October died this way: install it in provision.sh)"
|
||||
elif ! pf_meta=$(cargo metadata --no-deps --format-version 1 2>&1 >/tmp/br-meta-$BR_PID.json); then
|
||||
pf_class=preflight-manifest; pf_msg="$pf_meta"
|
||||
else
|
||||
members=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("\n".join(p["name"] for p in d["packages"]))' "/tmp/br-meta-$BR_PID.json" 2>/dev/null || true)
|
||||
for pkg in $(printf '%s\n' "$BR_CMD" | grep -oE '(^|[[:space:]])(-p|--package)[[:space:]=]+[A-Za-z0-9_.@:-]+' | awk '{print $NF}' | sed -E 's/^(-p|--package)=?//'); do
|
||||
grep -qx "$pkg" <<<"$members" && continue
|
||||
cargo pkgid --offline -p "$pkg" >/dev/null 2>&1 && continue
|
||||
pf_class=preflight-package; pf_msg="package '$pkg' is not in this workspace (and not a dependency): the -p argument names nothing"; break
|
||||
done
|
||||
# --features pkg/feat (or -F): the feature must exist on that member (the shipper's prove build died in 0 s twice on
|
||||
# 6 October, 19:22 and 19:51 UK, with a feature name; nothing kept the message)
|
||||
if [ -z "$pf_class" ]; then
|
||||
for spec in $(printf '%s\n' "$BR_CMD" | grep -oE '(^|[[:space:]])(-F|--features)[[:space:]=]+[A-Za-z0-9_./@:,-]+' | awk '{print $NF}' | sed -E 's/^(-F|--features)=?//' | tr ',' '\n'); do
|
||||
case "$spec" in */*) fpkg="${spec%%/*}"; feat="${spec#*/}" ;; *) continue ;; esac
|
||||
has=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); m=[p for p in d["packages"] if p["name"]==sys.argv[2]]; print("member" if not m else ("yes" if sys.argv[3] in m[0]["features"] else "no"))' "/tmp/br-meta-$BR_PID.json" "$fpkg" "$feat" 2>/dev/null || echo yes)
|
||||
if [ "$has" = no ]; then pf_class=preflight-feature; pf_msg="package '$fpkg' has no feature '$feat'"; break; fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
rm -f "/tmp/br-meta-$BR_PID.json"
|
||||
if [ -n "$pf_class" ]; then
|
||||
echo "build-remote: PRE-FLIGHT REFUSED ($pf_class): $pf_msg" >&2
|
||||
printf 'build-remote: RESULT rc=3 secs=0 compiles=0 class=%s\n' "$pf_class"
|
||||
BR_CLASS=$pf_class jsonlog 3 "$([ "$got" = measure ] && echo "" || echo "$got")" "$waited" "$(date +%s)" "$(date +%s)" 0 0 "" "" "" ""
|
||||
redlog 3 0 "$pf_class"; release_slot; exit 3
|
||||
fi
|
||||
fi
|
||||
# reproducible builds (main, 6 October 2026, the 0.3.14 repro): the commit's author time as SOURCE_DATE_EPOCH (mimalloc's
|
||||
# __DATE__/__TIME__), UTC; the target dir is fixed per target by the caller (prost's generated code embeds OUT_DIR)
|
||||
if [ -n "${BR_SDE:-}" ]; then export SOURCE_DATE_EPOCH="$BR_SDE" TZ=UTC; echo "build-remote: SOURCE_DATE_EPOCH=$BR_SDE TZ=UTC" >&2; fi
|
||||
|
|
@ -265,13 +358,36 @@ exec_before=$(stat_field "Compile requests executed"); hits_before=$(stat_field
|
|||
t1=$(date +%s)
|
||||
# in a subshell: a command string that carries `set -e` or `exit` ends only the subshell, never this runner (6 October 2026,
|
||||
# workers-remote.sh: both workers built, then the leaked set -e killed the runner before its RESULT line, reported as rc 101)
|
||||
( eval "$BR_CMD" )
|
||||
# the output is kept on the box (the last 400 lines) so a red row can be read after the agent's terminal is gone; both
|
||||
# streams stay where they were for the Mac (stdout to stdout, stderr to stderr), each teed into the run log
|
||||
RUN_LOG_DIR="$LOG_DIR/runs"; mkdir -p "$RUN_LOG_DIR"
|
||||
BR_RUN_LOG="$RUN_LOG_DIR/$BR_HOST-$BR_T0-$BR_PID.log"; export BR_RUN_LOG
|
||||
( eval "$BR_CMD" ) > >(tee -a "$BR_RUN_LOG") 2> >(tee -a "$BR_RUN_LOG" >&2)
|
||||
rc=$?
|
||||
wait
|
||||
t2=$(date +%s); secs=$(( t2 - t1 ))
|
||||
exec_after=$(stat_field "Compile requests executed"); hits_after=$(stat_field "Cache hits "); misses_after=$(stat_field "Cache misses ")
|
||||
compiles=$(( ${exec_after:-0} - ${exec_before:-0} )); hits=$(( ${hits_after:-0} - ${hits_before:-0} )); misses=$(( ${misses_after:-0} - ${misses_before:-0} ))
|
||||
printf 'build-remote: RESULT rc=%s secs=%s compiles=%s sccache_hits=%s sccache_misses=%s sccache_hits_total=%s sccache_misses_total=%s jobs=%s load=%s\n' \
|
||||
"$rc" "$secs" "$compiles" "$hits" "$misses" "${hits_after:-?}" "${misses_after:-?}" "${BR_JOBS:-measure}" "$(cut -d' ' -f1-3 /proc/loadavg)"
|
||||
tail -n 400 "$BR_RUN_LOG" > "$BR_RUN_LOG.tmp" 2>/dev/null && mv -f "$BR_RUN_LOG.tmp" "$BR_RUN_LOG"
|
||||
# the class of the run, from its exit, its duration and its output
|
||||
BR_CLASS=""
|
||||
if [ "$rc" != 0 ]; then
|
||||
# what the output says first (a failing test in a tiny crate also runs in under 3 s); instant is the rest
|
||||
if grep -qE '^(error\[E[0-9]+\]|error: could not compile)' "$BR_RUN_LOG"; then BR_CLASS=compile-error
|
||||
elif grep -qE 'error: linking with|undefined reference to' "$BR_RUN_LOG"; then BR_CLASS=link-error
|
||||
elif grep -q 'test result: FAILED' "$BR_RUN_LOG"; then BR_CLASS=test-failure
|
||||
elif [ "$secs" -le 2 ] && [ "${compiles:-0}" -le 0 ]; then BR_CLASS=instant
|
||||
else BR_CLASS=other; fi
|
||||
elif [[ "$BR_CMD" == cargo\ test* ]] && { [[ "$BR_CMD" == *" -- "* ]] || grep -qE -- '--(lib|tests|bins|all-targets)[[:space:]]+[^-[:space:]]' <<<"$BR_CMD"; } \
|
||||
&& grep -q '^running 0 tests' "$BR_RUN_LOG" && ! grep -qE '^running [1-9][0-9]* tests?' "$BR_RUN_LOG"; then
|
||||
# a filter that matched no test anywhere: the run was a wasted round trip, and the agent would have read "ok"
|
||||
BR_CLASS=no-test-matched; rc=3
|
||||
echo "build-remote: REFUSED after the run: the test filter matched no test in any binary (every 'running 0 tests'); check the name" >&2
|
||||
fi
|
||||
export BR_CLASS
|
||||
printf 'build-remote: RESULT rc=%s secs=%s compiles=%s sccache_hits=%s sccache_misses=%s sccache_hits_total=%s sccache_misses_total=%s jobs=%s load=%s class=%s\n' \
|
||||
"$rc" "$secs" "$compiles" "$hits" "$misses" "${hits_after:-?}" "${misses_after:-?}" "${BR_JOBS:-measure}" "$(cut -d' ' -f1-3 /proc/loadavg)" "${BR_CLASS:-ok}"
|
||||
jsonlog "$rc" "$([ "$got" = measure ] && echo "" || echo "$got")" "$waited" "$t1" "$t2" "$secs" "$compiles" "$hits" "$misses" "${hits_after:-}" "${misses_after:-}"
|
||||
if [ "$got" = measure ]; then : > "$SLOTS_DIR/measure"; else : > "$SLOTS_DIR/build-$got"; fi
|
||||
[ "$rc" = 0 ] || redlog "$rc" "$secs" "$BR_CLASS"
|
||||
release_slot
|
||||
exit "$rc"
|
||||
|
|
|
|||
|
|
@ -584,13 +584,14 @@ def main():
|
|||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scenario", default="all")
|
||||
ap.add_argument("--seeds", type=int, default=3)
|
||||
ap.add_argument("--seed-base", type=int, default=7, help="first seed (default 7, the lane's runs; the box's capacity sweep passes 1 to 1,000)")
|
||||
ap.add_argument("--rules", default="igneum,kaspa")
|
||||
ap.add_argument("--base", action="store_true", help="also run the base simulator's profiles through each rule")
|
||||
ap.add_argument("--jobs", type=int, default=4)
|
||||
ap.add_argument("--hop-hours", type=int, default=24)
|
||||
ap.add_argument("--ts-rules", default="kaspa", help="timestamp rules the forger obeys: kaspa or tight (README.md)")
|
||||
args = ap.parse_args()
|
||||
seeds = list(range(7, 7 + args.seeds))
|
||||
seeds = list(range(args.seed_base, args.seed_base + args.seeds))
|
||||
rules = args.rules.split(",")
|
||||
want = args.scenario.split(",") if args.scenario != "all" else ["hop", "pulse", "ts", "osc", "epoch", "pollute"]
|
||||
jobs = []
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ def main(argv=None):
|
|||
ap.add_argument("--selfish-run", type=float, default=600.0, help="seconds of the selfish-lead strategy per episode")
|
||||
ap.add_argument("--out", default="")
|
||||
ap.add_argument("--json", default="")
|
||||
ap.add_argument("--seed-base", type=int, default=0, help="offset added to every episode seed (the box's capacity sweep walks seeds 1 to 1,000 with --seeds 1)")
|
||||
args = ap.parse_args(argv)
|
||||
delays = [float(x) for x in args.delays.split(",")]
|
||||
shares = [float(x) for x in args.shares.split(",")]
|
||||
|
|
@ -364,14 +365,14 @@ def main(argv=None):
|
|||
("%g" % args.bps), args.k, args.honest, args.seeds), ""]
|
||||
out.append("Generated by `sim/horizon/consensus-security/ghostdag_sim.py` on %s. Every number is this simulator's; the model and its limits are in the file header." % time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()))
|
||||
out.append("")
|
||||
results = {"bps": args.bps, "k": args.k, "cells": []}
|
||||
results = {"bps": args.bps, "k": args.k, "seed_base": args.seed_base, "seeds": args.seeds, "cells": []}
|
||||
|
||||
# 1. honest-only baseline per delay
|
||||
out.append("## 1. Honest parallelism alone: natural selected-chain reorg depth at honest miner 0 (chain blocks), %g s per episode" % (args.warm + args.post))
|
||||
out.append("")
|
||||
rows = []
|
||||
for d in delays:
|
||||
eps = [episode(args.bps, args.k, d, 0.0, "hold", 0.0, 0, args.honest, args.warm + args.post, 0.0, 1000 + s) for s in range(args.seeds)]
|
||||
eps = [episode(args.bps, args.k, d, 0.0, "hold", 0.0, 0, args.honest, args.warm + args.post, 0.0, 1000 + args.seed_base + s) for s in range(args.seeds)]
|
||||
p99 = max(e["natural_p99"] for e in eps)
|
||||
mx = max(e["natural_max"] for e in eps)
|
||||
rows.append(["%g" % d, "%.2f" % (args.bps * d), "%.0f" % p99, mx])
|
||||
|
|
@ -388,7 +389,7 @@ def main(argv=None):
|
|||
for d in delays:
|
||||
for H in shares:
|
||||
for T in holds:
|
||||
eps = [episode(args.bps, args.k, d, H, "hold", T, 0, args.honest, args.warm, args.post, 2000 + s) for s in range(args.seeds)]
|
||||
eps = [episode(args.bps, args.k, d, H, "hold", T, 0, args.honest, args.warm, args.post, 2000 + args.seed_base + s) for s in range(args.seeds)]
|
||||
won = np.mean([e["won"] for e in eps])
|
||||
reorg = [e["reorg"] for e in eps]
|
||||
age = [e["fork_age"] for e in eps if e["won"]]
|
||||
|
|
@ -412,7 +413,7 @@ def main(argv=None):
|
|||
d = delays[1] if len(delays) > 1 else delays[0]
|
||||
rows = []
|
||||
for H in shares:
|
||||
eps = [episode(args.bps, args.k, d, H, "selfish", 0, 6, args.honest, 60.0, args.selfish_run, 3000 + s) for s in range(max(3, args.seeds // 4))]
|
||||
eps = [episode(args.bps, args.k, d, H, "selfish", 0, 6, args.honest, 60.0, args.selfish_run, 3000 + args.seed_base + s) for s in range(max(3, args.seeds // 4))]
|
||||
ab = sum(e["att_blue"] for e in eps)
|
||||
at = sum(e["att_blocks"] for e in eps)
|
||||
hb = sum(e["hon_blue"] for e in eps)
|
||||
|
|
|
|||
BIN
site/img/app-cards-dark.webp
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
site/img/app-cards-light.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
site/img/app-overview-dark.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
site/img/app-overview-light.webp
Normal file
|
After Width: | Height: | Size: 77 KiB |
605
site/index.html
|
|
@ -58,40 +58,132 @@
|
|||
</script>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* home page block (docs/plans/site-ui-3.md section 3, redrawn 6 Oct 2026: the scene is the page); tokens and components are in /site.css */
|
||||
/* the header on this page only: the wordmark, Litepaper, Live and Download, 56 px, transparent over the hero */
|
||||
.nav{position:absolute;left:0;right:0;top:0;background:transparent;border-bottom:0;backdrop-filter:none;-webkit-backdrop-filter:none;z-index:40}
|
||||
.nav .wrap{height:56px}
|
||||
.nav .links a:not([data-nav="litepaper"]):not([data-nav="live"]){display:none}
|
||||
.nav .links a[data-nav="live"]::after{content:""}
|
||||
.nav .cta{min-height:36px;padding:6px 14px;font-size:14px}
|
||||
.nav-sheet a:not([data-nav="litepaper"]):not([data-nav="live"]):not(.btn){display:none}
|
||||
.hero{padding:calc(56px + var(--s-6)) 0 0}
|
||||
.hero .wrap{text-align:center}
|
||||
.hero h1{font-size:clamp(26px,3.6vw,44px);font-weight:700;line-height:1.15;letter-spacing:-.01em;max-width:24ch;margin:0 auto var(--s-5)}
|
||||
.hero .cta{justify-content:center;margin-bottom:var(--s-5)}
|
||||
.bleed{width:100%;padding:0 var(--gutter)}
|
||||
.bleed canvas{width:100%;height:min(54vh,520px);min-height:300px;display:block;border-radius:var(--r-card);background:var(--row);border:1px solid var(--line)}
|
||||
.caption{margin:var(--s-3) auto 0;font:400 13px/1.5 var(--f-mono);color:var(--bone);min-height:1.5em;max-width:var(--max);padding:0 var(--gutter);text-align:left}
|
||||
.caption.muted{color:var(--ash)}
|
||||
.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4)}
|
||||
.fact{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r-card);padding:var(--s-5);display:flex;flex-direction:column;gap:var(--s-2);min-width:0}
|
||||
.fact .v{font:700 var(--t-num)/1 var(--f-display);color:var(--bone);letter-spacing:-.02em;overflow-wrap:anywhere}
|
||||
.fact .v.state{font:500 var(--t-base)/1.3 var(--f-mono);color:var(--ash)}
|
||||
.fact p{margin:0;color:var(--ink-2);font-size:var(--t-base)}
|
||||
.fact .k{font:500 var(--t-sm)/1.4 var(--f-mono);letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
|
||||
.dl-row{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3)}
|
||||
.dl-row a{display:flex;flex-direction:column;gap:4px;padding:14px 16px;border:1px solid var(--line);border-radius:var(--r-row);background:var(--graphite);color:var(--bone);font-weight:600;min-width:0}
|
||||
.dl-row a:hover{text-decoration:none;border-color:var(--line-2);background:var(--hover)}
|
||||
.dl-row a.primary{background:var(--ember);border-color:var(--ember);color:var(--ember-ink)}
|
||||
.dl-row a.primary:hover{background:var(--ember-hi)}
|
||||
.dl-row a span{font:400 var(--t-sm)/1.4 var(--f-mono);opacity:.85}
|
||||
/* home page block (docs/plans/site-ui-3.md section 3); the tokens, type and components are in /site.css */
|
||||
.hero{padding:calc(var(--sec) * .7) 0 var(--s-6)}
|
||||
.hero-grid{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);gap:var(--s-6);align-items:start}
|
||||
.hero-grid{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);gap:var(--s-6);align-items:start}
|
||||
.hero .wrap{text-align:center}.hero .eyebrow{justify-content:center}.hero h1{font-size:var(--t-hero);font-weight:900;line-height:.98;letter-spacing:-.02em;margin:var(--s-3) auto var(--s-4);max-width:14ch}.hero .lead{margin:0 auto var(--s-5);text-align:center}.hero .cta{justify-content:center;margin-bottom:var(--s-5)}
|
||||
.bleed{width:100%;padding:0 var(--gutter);margin-bottom:var(--s-6)}.bleed .card.viz{max-width:none}.bleed .scene canvas{height:min(56vh,520px);min-height:320px}
|
||||
.hero .tiles.facts{text-align:left}.hero .netstrip{text-align:left}
|
||||
.hero .lead{margin-bottom:var(--s-5)}
|
||||
.hero .cta{align-items:center}
|
||||
.hero .fine{font-size:var(--t-base);color:var(--ash);margin:var(--s-4) 0 0;max-width:62ch}
|
||||
.verify .verify-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-4)}
|
||||
.verify .verify-head .eyebrow{margin:0}
|
||||
.verify-row{display:flex;gap:var(--s-4);align-items:center;margin-bottom:var(--s-4)}
|
||||
.verify .tick{width:44px;height:44px;border-radius:12px;background:var(--ember);display:grid;place-items:center;flex:none}
|
||||
.verify .tick svg{stroke:var(--ember-ink)}
|
||||
.verify h3{margin:0 0 4px}
|
||||
.verify p{margin:0;color:var(--ash);font-size:var(--t-base)}
|
||||
.verify-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--s-2)}
|
||||
.verify .cell{background:var(--row);border:1px solid var(--line);border-radius:var(--r-row);padding:10px 12px;min-width:0}
|
||||
.verify .cell .k{font:500 var(--t-xs)/1.4 var(--f-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.verify .cell .v{font:500 14px/1.4 var(--f-mono);color:var(--bone);margin-top:2px;overflow-wrap:anywhere}
|
||||
.verify .cell .v.hot{color:var(--molten-text)}
|
||||
.facts{margin-top:var(--s-6)}
|
||||
.shots{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4)}
|
||||
.shot{margin:0;min-width:0}
|
||||
.shot img{width:100%;height:auto;border-radius:var(--r-row);border:1px solid var(--line);display:block}
|
||||
.shots .shot img{aspect-ratio:16/10;object-fit:cover;object-position:top}
|
||||
.shot figcaption{font:400 var(--t-sm)/1.5 var(--f-mono);color:var(--ash);margin-top:var(--s-2)}
|
||||
.shot .img-light{display:none}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]) .img-dark{display:none}:root:not([data-theme="dark"]) .img-light{display:block}}
|
||||
:root[data-theme="light"] .img-dark{display:none}:root[data-theme="light"] .img-light{display:block}
|
||||
.app-lines{list-style:none;padding:0;margin:var(--s-5) 0 0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3)}
|
||||
.app-lines li{background:var(--row);border:1px solid var(--line);border-radius:var(--r-row);padding:var(--s-4);min-width:0}
|
||||
.app-lines li{color:var(--ink-2);font-size:var(--t-base);line-height:1.5}
|
||||
.app-lines strong{color:var(--bone);display:block;margin-bottom:4px}
|
||||
.app-dl{max-width:560px}
|
||||
.wallet-card{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--s-5);align-items:center;margin-top:var(--s-6)}
|
||||
.wallet-card h3{margin:0 0 var(--s-2)}
|
||||
.wallet-card p{color:var(--ink-2);font-size:var(--t-base);line-height:1.5;margin:0}
|
||||
.wallet-card .dl-list{margin-bottom:0}
|
||||
@media (max-width:900px){.shots,.wallet-card,.app-lines{grid-template-columns:1fr}}
|
||||
.netstrip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);margin-top:var(--s-4)}
|
||||
.ns{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r-row);padding:12px 14px;min-width:0}
|
||||
.ns .k{font:500 var(--t-xs)/1.4 var(--f-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.ns .v{font:700 clamp(20px,2.2vw,28px)/1.1 var(--f-display);color:var(--bone);margin-top:4px;letter-spacing:-.01em;overflow-wrap:anywhere;transition:color .4s}
|
||||
.ns .v.tick,.cards .eta.tick{color:var(--molten-text)}
|
||||
.ns .s{font:400 var(--t-sm)/1.4 var(--f-mono);color:var(--ash);margin-top:4px}
|
||||
@media (max-width:700px){.netstrip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cards td:first-child{color:var(--bone);font-weight:600;white-space:nowrap}
|
||||
.cards .eta{transition:color .4s}
|
||||
.fair{margin:var(--s-5) 0 0;font-size:var(--t-lg);line-height:1.5;color:var(--ink-2);max-width:68ch}
|
||||
.fair b{color:var(--bone)}
|
||||
.scene canvas{height:min(56vh,520px);min-height:320px}
|
||||
.facts .tile .k{text-wrap:pretty}
|
||||
.scene-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--s-3) var(--s-5);margin-bottom:var(--s-3)}
|
||||
.scene-head .state{margin:0}
|
||||
.scene{position:relative}
|
||||
.caption{margin:var(--s-3) 0 0;font-size:13px;line-height:1.5;color:var(--bone);min-height:1.5em}.caption.muted{color:var(--ash)}
|
||||
.scene canvas{width:100%;height:360px;border-radius:var(--r-row);background:var(--row);border:1px solid var(--line);display:block}
|
||||
.tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:var(--r-row);padding:10px 12px;font:400 12px/1.5 var(--f-mono);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 14px 40px var(--shadow)}
|
||||
.tip b{color:var(--bone);font-weight:500;overflow-wrap:anywhere}
|
||||
.tip[hidden]{display:none}
|
||||
.scene-foot{display:flex;flex-wrap:wrap;justify-content:space-between;gap:var(--s-3) var(--s-5);align-items:center;margin-top:var(--s-4)}
|
||||
.scene-foot .legend{margin:0}
|
||||
.scene-foot .btn{min-height:36px;padding:6px 14px;font-size:14px}
|
||||
.program pre{min-height:168px;color:var(--ink-2)}
|
||||
.program .head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3)}
|
||||
.program .head .eyebrow{margin:0}
|
||||
.program .next{font:500 var(--t-sm)/1.4 var(--f-mono);color:var(--molten-text)}
|
||||
.feed{display:flex;flex-direction:column;border-top:1px solid var(--line)}
|
||||
.feed div{display:flex;justify-content:space-between;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line);font:400 14px/1.4 var(--f-mono);color:var(--ink-2)}
|
||||
.feed div span:last-child{color:var(--ash)}
|
||||
.proofs .card{display:flex;flex-direction:column;gap:var(--s-3)}
|
||||
.proofs .card p{margin:0;color:var(--ink-2);font-size:var(--t-base)}
|
||||
.proofs .card .more{margin-top:auto;font-weight:500}
|
||||
.cmp td:first-child{color:var(--ash);font-family:var(--f-mono);font-size:14px;white-space:nowrap}
|
||||
.cmp th:last-child,.cmp td:last-child{color:var(--bone)}
|
||||
.download{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--s-6);align-items:center}
|
||||
.download .shot img{border-radius:var(--r-row);border:1px solid var(--line)}
|
||||
.download .shot figcaption{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-2);font-family:var(--f-mono)}
|
||||
.dl-list{display:flex;flex-direction:column;gap:var(--s-2);margin:var(--s-4) 0}
|
||||
.dl-list a{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);padding:12px 16px;border:1px solid var(--line);border-radius:var(--r-row);background:var(--graphite);color:var(--bone);font-weight:600}
|
||||
.dl-list a:hover{text-decoration:none;border-color:var(--line-2);background:var(--hover)}
|
||||
.dl-list a.primary{background:var(--ember);border-color:var(--ember);color:var(--ember-ink)}
|
||||
.dl-list a.primary:hover{background:var(--ember-hi)}
|
||||
.dl-list a span{font:400 var(--t-sm)/1.4 var(--f-mono);opacity:.85}
|
||||
.notice{font-size:var(--t-base);color:var(--ink-2);margin:var(--s-3) 0 0}
|
||||
.notice b{color:var(--bone);font-weight:600}
|
||||
.ledger-line{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--s-3);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:var(--s-4) 0}
|
||||
.ledger-line p{margin:0;color:var(--ink-2)}
|
||||
@media (max-width:900px){.facts,.dl-row{grid-template-columns:1fr}.hero{padding-top:calc(56px + var(--s-5))}.bleed canvas{height:300px}}
|
||||
@media (max-width:720px){.hero .cta .btn{width:100%}}
|
||||
.pillars{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4)}
|
||||
.pillar{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.pillar svg{stroke:var(--ember);margin-bottom:var(--s-2)}
|
||||
.pillar p{color:var(--ink-2);margin:0;font-size:var(--t-base)}
|
||||
.pillar a{font-weight:500;margin-top:auto}
|
||||
.wallet-states{display:flex;flex-wrap:wrap;gap:var(--s-2);margin-bottom:var(--s-4)}
|
||||
.wstate{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:var(--r-pill);padding:6px 12px 6px 8px;font:500 var(--t-sm)/1.4 var(--f-mono);color:var(--ink-2);background:var(--row)}
|
||||
.wstate i{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block}
|
||||
.wstate.block i{background:var(--molten)}
|
||||
.wstate.final i{background:var(--ember)}
|
||||
.wstate small{color:var(--ash);font-weight:400}
|
||||
.phases{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-3)}
|
||||
.phase{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r-row);padding:var(--s-4);display:flex;flex-direction:column;gap:6px}
|
||||
.phase .w{font:500 var(--t-xs)/1.4 var(--f-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.phase .n{font:700 var(--t-base)/1.3 var(--f-display);color:var(--bone)}
|
||||
.phase .g{font-size:14px;color:var(--ink-2)}
|
||||
.phase.active{border-color:var(--ember-40)}
|
||||
.phase.active .w{color:var(--ember-text)}
|
||||
.phase.done .n{color:var(--ash)}
|
||||
.log{display:flex;flex-direction:column;border-top:1px solid var(--line);margin-top:var(--s-4)}
|
||||
.log>div{display:grid;grid-template-columns:110px 1fr;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line);font-size:var(--t-base);color:var(--ink-2)}
|
||||
.log>div .d{font:500 var(--t-sm)/1.6 var(--f-mono);color:var(--ash)}
|
||||
.log>div.more{display:none}
|
||||
.log.open>div.more{display:grid}
|
||||
.log-toggle{margin-top:var(--s-3);align-self:flex-start;background:none;border:1px solid var(--line);border-radius:var(--r-btn);color:var(--bone);font:600 14px/1.2 var(--f-sans);padding:8px 14px;cursor:pointer}
|
||||
.now{display:inline-flex;align-items:center;gap:8px;font:500 var(--t-sm)/1.4 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ash)}
|
||||
.split2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--s-6);align-items:center}
|
||||
.bar{height:14px;border-radius:7px;overflow:hidden;display:flex;background:var(--row);border:1px solid var(--line)}
|
||||
.bar div{height:100%}
|
||||
.band{background:var(--ember);color:var(--ember-ink);padding:var(--s-6) 0;margin-top:var(--sec)}
|
||||
.band .wrap{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:var(--s-4)}
|
||||
.band h2,.band p{color:var(--ember-ink);margin:0}
|
||||
.band p{opacity:.85;margin-top:6px;max-width:60ch}
|
||||
.band .btn{background:var(--ember-ink);color:var(--ember);border-color:var(--ember-ink)}
|
||||
.band .btn:hover{background:var(--ember-ink);opacity:.9}
|
||||
.band+.foot{margin-top:0}
|
||||
@media (max-width:900px){.hero-grid,.download,.split2{grid-template-columns:1fr}.pillars,.phases{grid-template-columns:1fr}.scene canvas{height:280px}}
|
||||
@media (max-width:560px){.verify-grid{grid-template-columns:1fr}.log>div{grid-template-columns:1fr;gap:2px}.scene canvas{height:240px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -146,26 +238,213 @@
|
|||
|
||||
<header class="hero" id="top">
|
||||
<div class="wrap">
|
||||
<h1>A proof-of-work chain for graphics cards, whose miners prove the blocks.</h1>
|
||||
<div class="eyebrow ember">GPUs are back · for good</div>
|
||||
<h1>Mined by GPUs.<br>Proven by fire.</h1>
|
||||
<p class="lead">A proof-of-work chain for graphics cards, whose miners prove the blocks.</p>
|
||||
<div class="cta">
|
||||
<a href="#get" class="btn primary">Download the miner</a>
|
||||
<a href="/litepaper" class="btn">Read the litepaper</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bleed">
|
||||
<canvas id="steps" aria-label="The live devnet, step by step: every square is a real block moving left as time passes; its outline, quarter cells, fill and ring show mined, shards being proven, proven and locked checkpoint"></canvas>
|
||||
<div class="card viz" id="viz-card">
|
||||
<div class="scene-head">
|
||||
<div class="state" id="viz-label"><span class="dot off"></span><span id="viz-word">connecting to the devnet observer</span></div>
|
||||
<div class="viz-stats mono"><span>chain block <b id="c-blocks">pending</b></span><span>shards proven <b id="c-proven">pending</b></span><span>last lock <b id="c-locked">pending</b></span><span>vote keys in 10 min <b id="c-miners">pending</b></span><span>hash rate <b id="c-hash">pending</b></span><span>blocks / s <b id="c-bps">pending</b></span></div>
|
||||
</div>
|
||||
<div class="scene">
|
||||
<canvas id="steps" aria-label="The live devnet, step by step: every square is a real block moving left as time passes; its outline, quarter cells, fill and ring show mined, shards being proven, proven and locked checkpoint"></canvas>
|
||||
</div>
|
||||
<p class="caption mono" id="steps-caption" aria-live="polite">Connecting to the devnet observer.</p>
|
||||
<div class="scene-foot">
|
||||
<div class="legend" id="legend-live">
|
||||
<span><i class="sw" style="border:2px solid var(--line-2)"></i>mined, pending</span>
|
||||
<span><i class="sw" style="border:2px solid var(--ember)"></i>mined, included</span>
|
||||
<span><i class="sw" style="border:2px solid var(--line-2);opacity:.45"></i>excluded</span>
|
||||
<span><i class="sw" style="border:2px solid var(--molten);background:linear-gradient(90deg,var(--molten) 50%,transparent 50%)"></i>shards being proven</span>
|
||||
<span><i class="sw" style="background:var(--ember)"></i>proven</span>
|
||||
<span><i class="sw" style="border:3px solid var(--ember);border-radius:50%;background:transparent"></i>locked checkpoint</span>
|
||||
</div>
|
||||
<div class="cta"><a href="/live" class="btn">The live page</a></div>
|
||||
</div>
|
||||
<p class="note" id="viz-note">One node is read every 2 s. Every square is a real block, released at a calm pace so each step can be seen; the counters read the chain. Chain block is the EVM block number. Shards proven is every shard paid on the chain so far. Last lock is the latest checkpoint the finality rule locked.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="tiles facts" aria-label="Four facts">
|
||||
<div class="tile"><div class="v">1 / s</div><div class="k">blocks, rising to 10</div></div>
|
||||
<div class="tile"><div class="v">~60 s</div><div class="k">to a proof at launch, the target. First GPU proof of a block: 1.4 s on an RTX 5090, 4 Oct 2026</div></div>
|
||||
<div class="tile"><div class="v">0</div><div class="k">premine</div></div>
|
||||
<div class="tile"><div class="v">4B</div><div class="k">IGN hard cap, ever</div></div>
|
||||
</div>
|
||||
<p class="note">100% of emission goes to miners and provers; the protocol carries no fee.</p>
|
||||
<div class="netstrip" id="netstrip" aria-label="The network right now">
|
||||
<div class="ns"><div class="k">Network hash rate</div><div class="v" id="n-hash">pending</div><div class="s">one node, read every 2 s</div></div>
|
||||
<div class="ns"><div class="k">Blocks, last 10 min</div><div class="v" id="n-blocks10">pending</div><div class="s">target 1 a second</div></div>
|
||||
<div class="ns"><div class="k">Latest block</div><div class="v"><span id="n-last">pending</span></div><div class="s" id="n-last-s">from the chain tip</div></div>
|
||||
<div class="ns"><div class="k">Shards proven and paid</div><div class="v" id="n-paid">pending</div><div class="s" id="n-paid-s">on the chain so far</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="caption mono" id="steps-caption" aria-live="polite">Connecting to the devnet observer.</p>
|
||||
</header>
|
||||
|
||||
<section id="facts" class="sec">
|
||||
<section id="prove" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="facts">
|
||||
<div class="fact"><div class="k">Live now</div><div class="v" id="f-hash">pending</div><p><span id="f-keys">pending</span> vote keys mined a block in the last ten minutes; a card runs several.</p></div>
|
||||
<div class="fact"><div class="k">Proven and paid</div><div class="v" id="f-paid">pending</div><p>shards proven and paid on the chain so far, <span id="f-paid10">pending</span> of them in the last ten minutes.</p></div>
|
||||
<div class="fact"><div class="k">The chip model</div><div class="v">5x to 9x</div><p>per joule for the strongest chip against an RTX 5090 today in our public model, about 2x once the lever now in its gates ships: <a href="/bench#counter-asic-2-0-the-numbers">the numbers</a>.</p></div>
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow">The chain, now</div>
|
||||
<h2>How the chain proves itself</h2>
|
||||
<p>Blocks every second, proven in shards, locked every 30 seconds. First live lock 4 Oct 2026: checkpoint 242, 77.4% of all weight, two hours after genesis.</p>
|
||||
</div>
|
||||
<p class="note" id="facts-note">One node is read every 2 s. The chip figures are a cost model, not a measurement.</p>
|
||||
<div class="grid c2" style="margin-top:var(--s-4)">
|
||||
<div class="card program">
|
||||
<div class="head"><div class="eyebrow">This hour's program</div><div class="next">next in <span id="countdown">59:59</span></div></div>
|
||||
<pre id="program" aria-label="A sample of the current hour's mining program"></pre>
|
||||
<p class="note">A new program every hour: a chip wired for one program is useless. A programmable chip is met by the latency-shadow work and the price per joule, not by surprise. First live swap 4 Oct 2026: Apple, NVIDIA and AMD kept hashing through it, 0 rejected blocks.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="eyebrow">Proofs sold to other chains</div>
|
||||
<div class="feed">
|
||||
<div><span>rollup · batch proof</span><span>at testnet</span></div>
|
||||
<div><span>bridge · state proof</span><span>at testnet</span></div>
|
||||
<div><span>rollup · fault proof</span><span>at testnet</span></div>
|
||||
<div><span>IGN burned from jobs</span><span>phase two</span></div>
|
||||
</div>
|
||||
<p class="note">The same cards sell proofs to rollups and bridges. Jobs are paid on the customer's chain at launch; settlement in IGN with a 10% burn follows the proof bridge in phase two. Live rows arrive with the public testnet. The public testnet is weeks away: three seed nodes and the public RPC are up, and it opens when the go checklist closes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note"><a href="/litepaper#proving">Read more in the litepaper</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="app" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow"><span data-product="full">Igneum Ember</span> · the app</div>
|
||||
<h2>The app you mine with</h2>
|
||||
<p>Your rate, what it costs a day and the chain itself, drawn live with your own blocks marked. Here it is, on a real rig.</p>
|
||||
</div>
|
||||
<div class="shots">
|
||||
<figure class="shot">
|
||||
<img src="/img/miner-dashboard.webp" width="2880" height="1800" class="img-dark" alt="The Mine page of Igneum Miner 0.3.14 on a three-card rig: 169 MH/s at 532 W, 84 blocks this run, and one row per card with its rate, watts, MH per watt, temperature, tune line and Tune button" loading="lazy" decoding="async">
|
||||
<img src="/img/miner-dashboard-light.webp" width="2880" height="1800" class="img-light" alt="The Mine page of Igneum Miner 0.3.14 in light mode" loading="lazy" decoding="async">
|
||||
<figcaption>Shipping now: the Mine page of 0.3.14 on the three-card Windows rig, live devnet.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img src="/img/app-overview-dark.webp" width="1440" height="1259" class="img-dark" alt="The Overview page of the next release: 511 MH/s, 629 W, £4.23 a day of electricity, 6,784 blocks this run; the chain drawn live, one lane per miner, this rig's blocks marked you, and locked checkpoints; the node line and the activity log" loading="lazy" decoding="async">
|
||||
<img src="/img/app-overview-light.webp" width="1440" height="1259" class="img-light" alt="The Overview page of the next release in light mode" loading="lazy" decoding="async">
|
||||
<figcaption>Next release: the Overview, with the chain live on your screen and your blocks marked. From the app in development.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img src="/img/app-cards-dark.webp" width="1440" height="1381" class="img-dark" alt="The Cards page of the next release: Ember Tune with Efficiency, Balanced and Maximum goals and the watts it saves; one row per card with its rate, watts, hashes per watt, cost a day and temperature; the RTX 5090's details open with its power cap, goal, Retune and the tune curve of hash rate against power" loading="lazy" decoding="async">
|
||||
<img src="/img/app-cards-light.webp" width="1440" height="908" class="img-light" alt="The Cards page of the next release in light mode" loading="lazy" decoding="async">
|
||||
<figcaption>Next release: the Cards page, every card tuned by Ember, cap and curve per card. From the app in development.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<ul class="app-lines">
|
||||
<li><strong>One click.</strong> The node, the miner, the prover and a wallet, installed together. Start, and the card mines.</li>
|
||||
<li><strong>Every card tuned.</strong> Ember Tune walks each card through its power steps and keeps the point with the most hashes per watt.</li>
|
||||
<li><strong>The chain on your screen.</strong> Every block as it lands, yours marked, shards proven, checkpoints locked. The same picture as the top of this page.</li>
|
||||
</ul>
|
||||
<div class="dl-list app-dl">
|
||||
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="primary">Windows <span data-dl-meta="miner-windows">v0.3.15 · 62.7 MB</span></a>
|
||||
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg">macOS <span data-dl-meta="miner-mac">v0.3.15 · 41.9 MB</span></a>
|
||||
<a href="/miner#get">Linux · HiveOS <span>on the miner page</span></a>
|
||||
</div>
|
||||
<div class="card wallet-card">
|
||||
<figure class="shot">
|
||||
<img src="/img/wallet-home.webp" width="2240" height="1560" alt="The Igneum Wallet window: a balance in IGN, an example address with Send and Receive, node and finality panels, and a history of block rewards" loading="lazy" decoding="async">
|
||||
</figure>
|
||||
<div>
|
||||
<div class="eyebrow">Igneum Wallet</div>
|
||||
<h3>A wallet that checks finality itself</h3>
|
||||
<p>Holds the key the miner makes for you, shows every reward and payout, and checks each one with the node's own code: pending, in a block, locked under a checkpoint it verified. macOS now, Windows next. MetaMask export too.</p>
|
||||
<div class="dl-list"><a data-dl="wallet-mac" href="https://dl.igneum.network/public/igneum-wallet-mac.dmg">macOS <span data-dl-meta="wallet-mac">v0.1.4 · 19.6 MB</span></a><a href="/wallet">The wallet page <span>how it checks</span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="earn" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow">Your card</div>
|
||||
<h2>What your card does here</h2>
|
||||
<p>Measured rates from the bench table, and the time a card alone takes to find a block at the network's hash rate right now. In a pool you are paid your share of every block instead.</p>
|
||||
</div>
|
||||
<div class="tbl"><table class="cards">
|
||||
<thead><tr><th>Card</th><th class="num">Hash rate</th><th class="num">Watts</th><th class="num">A block alone, right now</th><th>Measured</th></tr></thead>
|
||||
<tbody id="cards">
|
||||
<tr data-mhs="127.7"><td>NVIDIA RTX 5090</td><td class="num">127.7 MH/s</td><td class="num">227 W</td><td class="num eta">pending</td><td>6 Oct 2026, Ember Tune on the three-card Windows rig, 0.15% of rate traded for 84 W</td></tr>
|
||||
<tr data-mhs="28.8"><td>NVIDIA RTX 4070</td><td class="num">28.8 MH/s</td><td class="num">76 W</td><td class="num eta">pending</td><td>6 Oct 2026, Ember Tune on the same rig, no rate lost for 30 W</td></tr>
|
||||
<tr data-mhs="17.8"><td>AMD RX 9070 XT</td><td class="num">17.8 MH/s</td><td class="num">not logged</td><td class="num eta">pending</td><td>5 Oct 2026, the eGPU entry of the engineering log, the app's rate</td></tr>
|
||||
<tr data-mhs="26.7"><td>Apple M5 Max</td><td class="num">26.7 MH/s</td><td class="num">not logged</td><td class="num eta">pending</td><td>4 Oct 2026, the first live hourly swap</td></tr>
|
||||
<tr><td>NVIDIA RTX 4090</td><td class="num">not yet measured</td><td class="num"></td><td class="num"></td><td>Run the app and your row joins <a href="/miners">the bench table</a></td></tr>
|
||||
<tr><td>AMD RX 7900 XTX</td><td class="num">not yet measured</td><td class="num"></td><td class="num"></td><td>Run the app and your row joins <a href="/miners">the bench table</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="note" id="cards-note">The time to a block is the network hash rate divided by the card's rate, recomputed on every reading; it is an expectation, not a promise. Every row names its engineering log entry; nothing here is estimated.</p>
|
||||
<p class="fair"><b>Graphics cards only.</b> A new mining program every hour, so no chip is built for it. Your card proves the blocks as well as mining them. Every block pays 80% to its miner and 20% to the provers of that block; nothing to anyone else.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="check" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow">For the sceptic</div>
|
||||
<h2>Three things to check</h2>
|
||||
<p>Igneum states its limits first. Each of these is measured, published and open to anyone who wants to break it.</p>
|
||||
</div>
|
||||
<div class="card verify" style="margin-bottom:var(--s-4)">
|
||||
<div class="verify-head">
|
||||
<div class="eyebrow">This tab · light client</div>
|
||||
<div class="state" data-lc="badge"><span class="dot off"></span><span data-lc="badge-text">PREVIEW</span></div>
|
||||
</div>
|
||||
<div class="verify-row">
|
||||
<div class="tick"><svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7"></path></svg></div>
|
||||
<div>
|
||||
<h3>Browser checks Igneum</h3>
|
||||
<p data-lc="line">A checkpoint certificate, verified in this tab. Voter list from the node.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="verify-grid">
|
||||
<div class="cell"><div class="k">Block proof</div><div class="v" data-lc="proof" title="No execution proof is on the chain yet. The first off-chain GPU proof of a block was 4 October 2026">not yet</div></div>
|
||||
<div class="cell"><div class="k">Checkpoint</div><div class="v" data-lc="checkpoint">checking</div></div>
|
||||
<div class="cell"><div class="k">Proof system</div><div class="v" data-lc="system">BLS aggregate, version 1</div></div>
|
||||
<div class="cell"><div class="k">Verified in this tab</div><div class="v hot" data-lc="tab">checking</div></div>
|
||||
</div>
|
||||
<script type="module" src="/verify/verify.js"></script>
|
||||
</div>
|
||||
<p class="note" style="margin:0 0 var(--s-5)">Every card mines; today's app proves on a 24 GB NVIDIA card and mines and proves on 32 GB. Measured on 6 October 2026 on eleven rented cards with the patched server: every NVIDIA card from 8 GB proves, 10 GB and up mine and prove (<a href="/bench#prover-tiers-on-real-cards-the-rented-fleet-6-october-2026-branch-gpu-fleet">the log</a>); ships when the packaging row lands. AMD and Apple cards mine. The chip model is public: <a href="/bench#counter-asic-2-0-the-numbers">the numbers</a>.</p>
|
||||
<div class="grid c3 proofs">
|
||||
<div class="card">
|
||||
<div class="eyebrow">Chip economics</div>
|
||||
<h3>The chip model is public</h3>
|
||||
<p>In our public model the strongest chip reaches 5x to 9x per joule against an RTX 5090 today, about 2x once the lever now in its gates ships. The model, its inputs and what it does not cover are in the engineering log.</p>
|
||||
<a class="more" href="/bench#counter-asic-2-0-the-numbers">The numbers</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="eyebrow">Monero's technique, applied to GPUs</div>
|
||||
<h3>A program that keeps changing</h3>
|
||||
<p>The random program that has kept chips off Monero since 2019 (approximate), rebuilt for graphics cards. The program keeps changing on a schedule fixed at genesis; nobody touches it.</p>
|
||||
<div class="tbl" style="margin-top:var(--s-2)"><table class="cmp">
|
||||
<thead><tr><th>Property</th><th>RandomX</th><th>Igneum</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Who mines</td><td>CPUs</td><td>GPUs</td></tr>
|
||||
<tr><td>Program changes</td><td>Every hash</td><td>Every hour</td></tr>
|
||||
<tr><td>Memory</td><td>2 GB, fixed</td><td>2 GB, growing (the proposed schedule, fixed at the testnet genesis: 2 GB, doubling at years 4, 12 and 28)</td></tr>
|
||||
<tr><td>Verified by</td><td>Any CPU</td><td>Any CPU</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<a class="more" href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="eyebrow">Every criticism</div>
|
||||
<h3>What Igneum does not claim</h3>
|
||||
<p>Proof lag, chip economics, the market size, and the one rule external review will try hardest to break: the litepaper states the limits, and the ledger carries every criticism, answered or conceded.</p>
|
||||
<a class="more" href="/litepaper#limits">The limits</a>
|
||||
<a class="more" href="/ledger">The ledger</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">Built on the shoulders: Kaspa's GHOSTDAG and node, Monero's RandomX idea, Chia's class-group VDF, Ethereum's EVM, Succinct's SP1, BLS12-381 and Bitcoin's address format. What changed, why, and how it is measured: <a href="/litepaper#shoulders">the provenance section of the litepaper</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -174,26 +453,155 @@
|
|||
<div class="sec-head">
|
||||
<div class="eyebrow"><span data-product="full">Igneum Ember</span> · one click</div>
|
||||
<h2 id="mine">Install. Start. The card mines.</h2>
|
||||
<p><span data-product="full">Igneum Ember</span> finds your GPU, makes a wallet for you and runs the node, the miner and the prover as one app. The card mines; a 24 GB NVIDIA card proves as well.</p>
|
||||
</div>
|
||||
<div class="dl-row">
|
||||
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="primary">Windows <span data-dl-meta="miner-windows">v0.3.15 · 62.7 MB</span></a>
|
||||
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg">macOS <span data-dl-meta="miner-mac">v0.3.15 · 41.9 MB</span></a>
|
||||
<a href="/miner#get">Linux · HiveOS <span>on the miner page</span></a>
|
||||
<div class="download">
|
||||
<div>
|
||||
<div class="dl-list">
|
||||
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="primary">Windows <span data-dl-meta="miner-windows">v0.3.15 · 62.7 MB</span></a>
|
||||
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg">macOS <span data-dl-meta="miner-mac">v0.3.15 · 41.9 MB</span></a>
|
||||
<a href="/miner#get">Linux · HiveOS <span>on the miner page</span></a>
|
||||
</div>
|
||||
<p class="notice" data-devnet-notice><b>Devnet.</b> Coins have no value and the chain may be reset.</p>
|
||||
<p class="notice" data-testnet-notice>Public testnet: not yet open; the devnet build is here for people who want to look.</p>
|
||||
<p class="notice"><a href="https://discord.gg/beCy8G5ZR" rel="noopener">Join the Discord</a>: the miners, the releases and the bench numbers as they land. The public testnet is weeks away: three seed nodes and the public RPC are up, and it opens when the go checklist closes.</p>
|
||||
<p class="notice"><a href="/miner">Read more about the miner</a>. The protocol carries no fee. The <span data-product="name">Ember</span> software takes an optional 1% dev fee, like other GPU miners, off with one flag. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
|
||||
<div class="grid c2" style="margin-top:var(--s-5)">
|
||||
<div><h3 style="font-size:var(--t-base)">NVIDIA, AMD, Apple silicon</h3><p class="note" style="margin-top:4px">Found by itself. One worker per card, several identities each.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">A new program every hour, no pause</h3><p class="note" style="margin-top:4px">Compiled ahead and swapped in 0.01 ms on the live devnet, 0 rejected blocks.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">Signed updates at a safe moment</h3><p class="note" style="margin-top:4px">The card keeps mining through the download. The old version comes back if the new one fails to start.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">Four pages: Mine, Earnings, Prove, Settings</h3><p class="note" style="margin-top:4px">Every card is one row: rate, watts, MH per watt, pounds a day at your electricity price, a Tune button. The node is one line.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">Ember Tune, measured</h3><p class="note" style="margin-top:4px">An RTX 5090 from 311 W to 227 W for 0.15% of its rate; an RTX 4070 from 106 W to 76 W for none (the Windows rig, 6 Oct 2026). Every number in the bench table.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="shot" style="margin:0">
|
||||
<img src="/img/miner-dashboard.webp" width="2880" height="1800" alt="The Mine page of Igneum Miner 0.3.14 on a three-card PC: 169 MH/s at 532 W, one row per card with its rate, watts, MH per watt, temperature, tune line, Tune button and switch" loading="lazy" decoding="async">
|
||||
<figcaption><span data-product="caption">Igneum Ember (the app window still says Igneum Miner)</span> 0.3.14 on a Windows rig with an RTX 5090, RTX 4070 and RX 9070 XT, live devnet, 6 Oct 2026.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<p class="notice" data-devnet-notice><b>Devnet.</b> Coins have no value and the chain may be reset.</p>
|
||||
<p class="notice" data-testnet-notice>Public testnet: not yet open; the devnet build is here for people who want to look.</p>
|
||||
<p class="notice"><a href="/miner">Read more about the miner</a>. The protocol carries no fee. The one payment to the project is the Ember software's optional 1% dev fee, like other GPU miners, off with one flag. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
|
||||
<details id="testnet-terms" style="margin-top:var(--s-6)">
|
||||
<summary>What you agree to when you run the testnet miner</summary>
|
||||
<div class="grid c3">
|
||||
<p><b>No value.</b> Testnet IGN cannot be sold, bought or redeemed, now or at mainnet. There is no airdrop, no points scheme and no promise tied to testnet balances. Mainnet starts from an empty genesis.</p>
|
||||
<p><b>Resets.</b> The chain restarts from a fresh genesis when a consensus rule changes. Every reset is announced at least seven days ahead on this page and in the app. Balances, contracts and history do not carry over. The devnet that runs today resets without notice.</p>
|
||||
<p><b>What the app sends home.</b> The app version, a random machine id made at install, your operating system, the node version, the hash rate, and the app, node and miner logs (which name the address the card mines to). They go to the project's log intake, a service Igneum runs on Vercel, and are read by the maintainers to find faults. Never your seed phrase, never a key, never a file you did not make with the app. Nothing is sold or shared.</p>
|
||||
</div>
|
||||
<p class="note">Wallet set-up for MetaMask: <a href="/metamask">chain id, RPC and the one-click button</a>. The miner software takes an optional 1% fee, off with one flag; the protocol carries no fee to anyone.</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ledger" class="sec">
|
||||
<section id="build" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="ledger-line">
|
||||
<p>Every criticism, answered or conceded, and <a href="/litepaper#limits">what Igneum does not claim</a>.</p>
|
||||
<a href="/ledger" class="btn">The ledger</a>
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow">How it works</div>
|
||||
<h2>One chain, three jobs</h2>
|
||||
</div>
|
||||
<div class="pillars">
|
||||
<div class="pillar">
|
||||
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="12" rx="2"></rect><path d="M7 9h4M7 13h2M15 9v4M18 9v4M9 21h6"></path></svg>
|
||||
<h3>Mine</h3>
|
||||
<p>Any 4 GB card at launch, 8 GB from about year 4 under the proposed schedule, fixed at the testnet genesis: 2 GB, doubling at years 4, 12 and 28, approximate. 80% of each block to its finder.</p>
|
||||
<a href="#get">About the miner</a>
|
||||
</div>
|
||||
<div class="pillar">
|
||||
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4z"></path><path d="M9 12l2 2 4-4"></path></svg>
|
||||
<h3>Prove</h3>
|
||||
<p>The same card proves shards and sells proofs to other chains.</p>
|
||||
<a href="#prove">How proving pays</a>
|
||||
</div>
|
||||
<div class="pillar">
|
||||
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5M14 4l-4 16"></path></svg>
|
||||
<h3>Build</h3>
|
||||
<p>Ethereum bytecode, with the differences documented. Proofs of any computation from the miners' cards, verified by the chain itself. 20% of priority fees to the contracts that ran, stated at the fee levels that exist.</p>
|
||||
<a href="/litepaper#builders-ask">Deploy on Igneum</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note"><a href="/litepaper#building">Read more in the litepaper</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="wallet" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="split2">
|
||||
<div>
|
||||
<div class="eyebrow">Igneum Wallet · desktop</div>
|
||||
<h2>Your coins, your wallet</h2>
|
||||
<p class="lead" style="margin-top:var(--s-3)">Igneum Wallet holds the key the miner makes for you and verifies finality itself. MetaMask works too.</p>
|
||||
<div class="wallet-states" aria-label="The three states a transfer can be in">
|
||||
<span class="wstate"><i></i>pending <small>waiting for a block</small></span>
|
||||
<span class="wstate block"><i></i>in a block <small>carried, not yet covered</small></span>
|
||||
<span class="wstate final"><i></i>final, checkpoint 5 <small>under a checkpoint this wallet verified</small></span>
|
||||
</div>
|
||||
<div class="grid c1" style="gap:var(--s-3)">
|
||||
<div><h3 style="font-size:var(--t-base)">Sealed on your machine</h3><p class="note" style="margin-top:4px">24 words, three typed back, a password. Argon2id and XChaCha20-Poly1305. Nothing leaves.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">Rewards in one history</h3><p class="note" style="margin-top:4px">Block rewards, proving payouts and transfers. Send with the fee shown. Receive by a QR drawn locally.</p></div>
|
||||
<div><h3 style="font-size:var(--t-base)">Reads your own node</h3><p class="note" style="margin-top:4px">When the miner is installed, the wallet uses its node. Nobody else sees your balance.</p></div>
|
||||
</div>
|
||||
<div class="cta" style="margin-top:var(--s-5)">
|
||||
<a href="/wallet" class="btn">Read more about the wallet</a>
|
||||
<a href="/wallet#metamask" class="btn">MetaMask export</a>
|
||||
</div>
|
||||
<p class="note">macOS first, Windows next. Public testnet first. Download only from this domain. Nobody from Igneum will ask for your seed.</p>
|
||||
</div>
|
||||
<figure class="shot" style="margin:0">
|
||||
<img src="/img/wallet-home.webp" width="2240" height="1560" alt="The wallet window: a balance in IGN, an example address with Send and Receive, node and finality panels, and a history of block rewards" loading="lazy" decoding="async" style="border-radius:var(--r-row);border:1px solid var(--line)">
|
||||
<figcaption class="note" style="font-family:var(--f-mono);font-size:var(--t-sm)">Igneum Wallet on a private test network, 5 Oct 2026. The address is an example.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="journey" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<div class="eyebrow">The journey</div>
|
||||
<h2>Where Igneum is right now</h2>
|
||||
<div class="now" style="margin-top:var(--s-3)"><span class="dot live"></span><span id="stage-label">loading</span></div>
|
||||
<p>Six phases, four public gates, each a measurement published pass or fail. No calendar dates: each phase closes at its gate.</p>
|
||||
</div>
|
||||
<div class="phases" id="phases"></div>
|
||||
<p class="note">The log below is the engineering log's dated entries, newest first. Day one was 3 October 2026; several entries can share a date because the work is logged as it is run.</p>
|
||||
<div class="log" id="log"></div>
|
||||
<p class="note"><a href="/litepaper#roadmap">Read more in the litepaper</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="economics" class="sec">
|
||||
<div class="wrap">
|
||||
<div class="split2">
|
||||
<div>
|
||||
<div class="eyebrow">Economics</div>
|
||||
<h2>Every coin, mined</h2>
|
||||
<p class="lead" style="margin-top:var(--s-3)">Hard cap of 4 billion IGN, halving every two years for ever.</p>
|
||||
<div class="bar" id="bar" aria-label="Emission split"><div style="width:80%;background:var(--ember)"></div><div style="width:20%;background:var(--molten)"></div></div>
|
||||
<div class="legend" style="margin-top:var(--s-3)">
|
||||
<span><i class="sw" style="background:var(--ember)"></i>80% miners</span>
|
||||
<span><i class="sw" style="background:var(--molten)"></i>20% provers</span>
|
||||
<span><i class="sw" style="border:1px solid var(--line-2)"></i>0% anyone else in the protocol</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tiles" style="grid-template-columns:repeat(2,minmax(0,1fr))">
|
||||
<div class="tile"><div class="v">0</div><div class="k">premine</div></div>
|
||||
<div class="tile"><div class="v">0</div><div class="k">stake, anywhere</div></div>
|
||||
<div class="tile"><div class="v">0</div><div class="k">admin keys in consensus</div></div>
|
||||
<div class="tile"><div class="v">90%</div><div class="k">of blocks must signal to upgrade</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:var(--s-5)">No premine, no stake, no fee to any team in the protocol. Base fee burned. The one payment to the project is the Ember software's optional 1% dev fee, off with one flag. On the devnet today the coinbase's 20% output is burned under the tag igneum-proving-pool-v0, and provers are paid from a separate escrow in the execution state credited with the same 20%.</p>
|
||||
<p class="note"><a href="/litepaper#economics">Read more in the litepaper</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="band">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<h2>Read what Igneum does not claim.</h2>
|
||||
<p>The litepaper states the limits first. Proof lag, chip economics, the market size, and the one rule external review will try hardest to break.</p>
|
||||
</div>
|
||||
<a href="/litepaper#limits" class="btn">The litepaper</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- footer:start -->
|
||||
|
|
@ -263,20 +671,89 @@
|
|||
<script src="/live-steps.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var fmtN=function(v){return v===null||v===undefined?'pending':Number(v).toLocaleString('en-GB');};
|
||||
function fmtHash(h){if(h===null||h===undefined||!(h>0))return 'pending';return h>=1e9?(h/1e9).toFixed(1)+' GH/s':(h/1e6).toFixed(1)+' MH/s';}
|
||||
var cap=document.getElementById('steps-caption');
|
||||
// the feed's state is the caption while the feed is not live; the scene writes the caption while it is
|
||||
function onState(s,r){if(s==='live')return;cap.classList.add('muted');cap.textContent=s==='connecting'?'Connecting to the devnet observer.':s==='stale'?(r.charAt(0).toUpperCase()+r.slice(1))+'. The scene holds the last stored blocks.':'Live feed unavailable. The scene holds its last blocks; the live page shows the last stored state.';}
|
||||
function onData(d){var s=d.state,pv=d.proving;
|
||||
document.getElementById('f-hash').textContent=fmtHash(s.hashes_per_second_estimate);
|
||||
document.getElementById('f-keys').textContent=fmtN(s.miners_10m!==null&&s.miners_10m!==undefined?s.miners_10m:(d.miners||[]).length);
|
||||
var paid=document.getElementById('f-paid');if(!pv||!pv.supported){paid.textContent='no proving layer';paid.className='v state';}else if(!pv.active){paid.textContent='proving not active';paid.className='v state';}else{paid.textContent=fmtN(pv.paid_shards_total);paid.className='v';}
|
||||
document.getElementById('f-paid10').textContent=fmtN(pv&&pv.active?(pv.shards_paid_10m||0):null);
|
||||
var fn=d.finality,note=document.getElementById('facts-note');
|
||||
note.textContent='One node is read every 2 s. '+(fn&&fn.supported&&!fn.active?'Finality is paused: under two thirds of the weight is signing, so no checkpoint locks until it returns. ':fn&&fn.active&&fn.latest_locked_index?'The latest locked checkpoint is #'+fmtN(fn.latest_locked_index)+'. ':'')+(pv&&pv.active&&pv.median_proof_lag_s?'Proofs land a median '+Math.round(pv.median_proof_lag_s)+' s behind the tip. ':'')+'The chip figures are a cost model, not a measurement.';}
|
||||
var c=document.getElementById('steps');
|
||||
if(c&&window.IgneumFeed&&window.IgneumSteps){var steps=IgneumSteps.mount(c,{caption:cap});IgneumFeed.start({window:300,subs:[steps],onState:onState,onData:onData});}
|
||||
|
||||
// the live scene: the shared module reads /api/live; the counters read the chain, never the window (chain block,
|
||||
// shards paid on the chain, the latest locked checkpoint); the state word and the note say what the feed is doing
|
||||
var label=document.getElementById('viz-label'),word=document.getElementById('viz-word'),dot=label?label.querySelector('.dot'):null,note=document.getElementById('viz-note');
|
||||
var cb=document.getElementById('c-blocks'),cp=document.getElementById('c-proven'),cl=document.getElementById('c-locked'),cm=document.getElementById('c-miners'),ch=document.getElementById('c-hash');
|
||||
function setWord(state,reason){
|
||||
if(!word||!dot)return;
|
||||
var w=state==='live'?'live devnet':state==='stale'?reason:state==='failed'?'live feed unavailable':'connecting to the devnet observer';
|
||||
word.innerHTML=state==='live'?'<a href="/live">live devnet</a>':w;
|
||||
dot.className='dot '+(state==='live'?'live':state==='failed'?'bad':'off');
|
||||
label.className='state '+(state==='live'?'live':state==='failed'?'bad':'');
|
||||
if(state!=='live'&¬e){note.textContent=(state==='failed'?'The live feed is unavailable, so the scene holds its last blocks and the counters read pending.':state==='stale'?w.charAt(0).toUpperCase()+w.slice(1)+'. The scene holds the last stored blocks; the counters are the last the observer wrote.':'Connecting to the devnet observer.')+' The live page shows the last stored state.';}
|
||||
}
|
||||
// the network right now: the strip under the facts and the card picker's time to a block, from the same reply
|
||||
var lastTs=null,netHash=null;
|
||||
function flash(id,text){var el=document.getElementById(id);if(!el)return;if(el.textContent!==text){el.textContent=text;el.classList.add('tick');setTimeout(function(){el.classList.remove('tick');},700);}}
|
||||
function fmtDur(sec){if(!(sec>0))return 'pending';if(sec<90)return Math.round(sec)+' s';if(sec<5400)return Math.round(sec/60)+' min';if(sec<172800)return (sec/3600).toFixed(1)+' h';return Math.round(sec/86400)+' days';}
|
||||
function network(d){var s=d.state;netHash=s.hashes_per_second_estimate;
|
||||
flash('n-hash',fmtHash(netHash));
|
||||
var bpm=s.blocks_per_minute||[],last10=bpm.slice(-10).reduce(function(a,p){return a+(p[1]||0);},0);flash('n-blocks10',bpm.length?fmtN(last10):'pending');
|
||||
var nb=d.blocks[d.blocks.length-1];lastTs=nb?nb.ts:null;if(nb){document.getElementById('n-last-s').textContent=(nb.number!==null&&nb.number!==undefined?'block '+fmtN(nb.number):'block '+nb.hash.slice(0,8))+(nb.chain?', on the selected chain':', '+(nb.color==='blue'?'included':nb.color==='red'?'excluded':'pending'));}
|
||||
var pv=d.proving;flash('n-paid',!pv||!pv.supported?'no proving layer':!pv.active?'not active':fmtN(pv.paid_shards_total));document.getElementById('n-paid-s').textContent=pv&&pv.active?fmtN(pv.shards_paid_10m||0)+' in the last 10 min':'on the chain so far';
|
||||
var bps=document.getElementById('c-bps');if(bps)bps.textContent=s.blocks_per_second_60s!==null&&s.blocks_per_second_60s!==undefined?Number(s.blocks_per_second_60s).toFixed(2):'pending';
|
||||
document.querySelectorAll('#cards tr[data-mhs]').forEach(function(tr){var r=parseFloat(tr.getAttribute('data-mhs'))*1e6,el=tr.querySelector('.eta');if(!el)return;var t=netHash>0?fmtDur(netHash/r):'pending';if(el.textContent!==t){el.textContent=t;el.classList.add('tick');setTimeout(function(){el.classList.remove('tick');},700);}});}
|
||||
setInterval(function(){var el=document.getElementById('n-last');if(!el||lastTs===null)return;var age=Math.max(0,Math.round((Date.now()-lastTs)/1000));el.textContent=age+' s ago';},1000);
|
||||
function onData(d){network(d);
|
||||
var s=d.state,pv=d.proving,fn=d.finality;
|
||||
cb.textContent=fmtN(s.height);
|
||||
cp.textContent=!pv||!pv.supported?'no proving layer':!pv.active?'not active':fmtN(pv.paid_shards_total);
|
||||
cl.textContent=!fn||!fn.supported?'no finality':!fn.active?'finality paused'+(fn.latest_locked_index?', last #'+fmtN(fn.latest_locked_index):''):fn.latest_locked_index!==null&&fn.latest_locked_index!==undefined?'#'+fmtN(fn.latest_locked_index):'none yet';
|
||||
cm.textContent=fmtN(s.miners_10m!==null&&s.miners_10m!==undefined?s.miners_10m:(d.miners||[]).length);
|
||||
ch.textContent=fmtHash(s.hashes_per_second_estimate);
|
||||
if(note&&!s.stale){var lag=pv&&pv.active&&pv.median_proof_lag_s?Math.round(pv.median_proof_lag_s):null;
|
||||
note.textContent='One node is read every 2 s. Chain block is the EVM block number. Shards proven is every shard paid on the chain so far'+(pv&&pv.active?' ('+fmtN(pv.shards_paid_10m||0)+' in the last 10 minutes'+(lag?', landing a median '+lag+' s behind the tip, so the newest blocks on screen are still pending':'')+')':'')+'. Last lock is the latest checkpoint the finality rule locked. A card runs several vote keys. Every square is a real block released at a calm pace so each step can be seen; the caption follows the newest block that changed step.';}
|
||||
}
|
||||
// the step view: the shared feed polls /api/live (300 s window so late proofs still reach the caption) and pushes to the scene
|
||||
var stepsCanvas=document.getElementById('steps');
|
||||
if(stepsCanvas&&window.IgneumFeed&&window.IgneumSteps){var steps=IgneumSteps.mount(stepsCanvas,{caption:document.getElementById('steps-caption')});
|
||||
IgneumFeed.start({window:300,subs:[steps],onState:setWord,onData:onData});}
|
||||
|
||||
// hourly program ticker
|
||||
var ops=['rotl','rotr','xor','add','sub','mul','mulhi','mad','or'];
|
||||
function hex(){return '0x'+Math.floor(Math.random()*0xFFFFFFFF).toString(16).toUpperCase().padStart(8,'0');}
|
||||
function reg(){return 'r'+Math.floor(Math.random()*8);}
|
||||
function line(){var o=ops[Math.floor(Math.random()*ops.length)];var r=Math.random();
|
||||
if(r<0.25)return reg()+' ^= load(ds, '+reg()+' & MASK)';
|
||||
if(r<0.35)return reg()+' = shfl_xor('+reg()+', '+[1,2,4,8,16][Math.floor(Math.random()*5)]+')';
|
||||
if(o==='mad')return reg()+' = mad('+reg()+', '+reg()+' | 1, '+reg()+')';
|
||||
if(o==='rotl'||o==='rotr')return reg()+' = '+o+'('+reg()+', '+Math.floor(Math.random()*31+1)+')';
|
||||
if(o==='mulhi')return reg()+' = mulhi('+reg()+', '+hex()+')';
|
||||
return reg()+' = '+reg()+' '+({xor:'^',add:'+',sub:'-',mul:'*',or:'|'})[o]+' '+(Math.random()<0.5?reg():hex());}
|
||||
var pre=document.getElementById('program');
|
||||
function program(){var ls=['// seed from a locked checkpoint, through a 10 minute delay'];for(var i=0;i<5;i++)ls.push(line());ls.push('// 64 instructions x 8 iterations, regenerated every hour');return ls;}
|
||||
function type(ls){pre.textContent='';var i=0,j=0,out='';function t(){if(i>=ls.length)return;if(j<=ls[i].length){out=ls.slice(0,i).join('\n')+(i?'\n':'')+ls[i].slice(0,j);pre.textContent=out;j++;setTimeout(t,reduce?0:14);}else{i++;j=0;t();}}t();}
|
||||
type(program());
|
||||
if(!reduce)setInterval(function(){type(program());},16000);
|
||||
var cd=document.getElementById('countdown');
|
||||
function countdown(){var n=new Date();var s=3599-(n.getMinutes()*60+n.getSeconds());cd.textContent=String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');}
|
||||
countdown();setInterval(countdown,1000);
|
||||
|
||||
// journey: the build inlines journey.json into #journey-data, so the phases and the log render in the same pass as the
|
||||
// page (no fetch, no layout shift); the fetch is the fallback for an unbuilt copy
|
||||
var MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
function day(iso){var m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(iso||'');return m?(+m[3])+' '+MON[+m[2]-1]+' '+m[1]:(iso||'');}
|
||||
function journey(j){
|
||||
var ph=document.getElementById('phases'),lg=document.getElementById('log'),lab=document.getElementById('stage-label');
|
||||
var active=j.phases.filter(function(p){return p.status==='active';});
|
||||
var names=active.map(function(p){return p.name;}),list=names.length>1?names.slice(0,-1).join(', ')+' and '+names[names.length-1]:names.join('');
|
||||
lab.textContent='now · '+list+' · updated '+day(j.updated);
|
||||
j.phases.forEach(function(p,i){var d=document.createElement('div');d.className='phase '+p.status;d.innerHTML='<div class="w">'+p.when+'</div><div class="n">'+(i+1)+'. '+p.name+'</div>'+(p.gate?'<div class="g">Gate: '+p.gate+'</div>':'<div class="g">'+p.line+'</div>');ph.appendChild(d);});
|
||||
var SHOW=8,last='';
|
||||
j.log.forEach(function(e,i){var d=document.createElement('div');if(i>=SHOW)d.className='more';var same=e.date===last;last=e.date;
|
||||
d.innerHTML='<span class="d"'+(same?' aria-hidden="true"':'')+'>'+(same?'':day(e.date))+'</span><span>'+(e.short||e.text)+'</span>';lg.appendChild(d);});
|
||||
if(j.log.length>SHOW){var t=document.createElement('button');t.type='button';t.className='log-toggle';t.setAttribute('aria-expanded','false');t.textContent='Show all '+j.log.length+' entries';
|
||||
t.onclick=function(){var open=lg.classList.toggle('open');t.setAttribute('aria-expanded',open?'true':'false');t.textContent=open?'Show the latest '+SHOW:'Show all '+j.log.length+' entries';};lg.appendChild(t);}
|
||||
}
|
||||
var inline=document.getElementById('journey-data'),data=null;
|
||||
try{if(inline&&inline.textContent.trim())data=JSON.parse(inline.textContent);}catch(e){data=null;}
|
||||
if(data)journey(data);
|
||||
else fetch('/journey.json').then(function(r){return r.json();}).then(journey).catch(function(){document.getElementById('stage-label').textContent='phase 3 · devnet';});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -207,48 +207,48 @@
|
|||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Sim/economy: mining versus proving under stress, agent-based",
|
||||
"short": "Economy simulation: mining versus proving under stress"
|
||||
"text": "One-click Windows workers: what the Apple M5 Max could measure",
|
||||
"short": "One-click Windows workers"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Difficulty rule under attack: pool hopping, pulsed rental, timestamp stretching, short-lane oscillation, epoch games, polluted window, block flood",
|
||||
"short": "Difficulty rule attacked seven ways"
|
||||
"text": "First finality lock on the live devnet: checkpoint 242 at 77.4% of all weight, two hours after genesis",
|
||||
"short": "First live finality lock: 77.4% of weight, 17 voters"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Difficulty rule: timestamp attack fixed , simulator regression, 3-node forger test",
|
||||
"short": "Timestamp attack on the difficulty rule fixed"
|
||||
"text": "The gfx1036 worker fault and what the Apple M5 Max could and could not reproduce",
|
||||
"short": "The gfx1036 worker fault and what the Apple M5 Max could and could…"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Devnet-v4 integration: nine branches merged, 3-node test network on the merged node, Windows cross-build",
|
||||
"short": "Devnet v4: nine branches merged into one node"
|
||||
"text": "A node 60 s behind the clock is silently dead",
|
||||
"short": "A node 60 s behind the clock is silently dead"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Generator version 2 adopted: exact load count, fresh-source loads, program acceptance; every vector re-cut, three workers re-checked, 20,000-program census, devnet-v4 binaries rebuilt",
|
||||
"short": "Generator v2 adopted: every hash does 128 distinct reads"
|
||||
"text": "First machine on the Igneum Miner app: the RTX 5090 Windows rig's RTX 5090 at 118 MH/s, via Setup.exe",
|
||||
"short": "First machine on the one-click app: a 5090 at 118 MH/s"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Proving v0 on the RTX 5090: first GPU proof of an Igneum block",
|
||||
"short": "First GPU proof of an Igneum block: 1.4 s on an RTX 5090"
|
||||
"text": "Difficulty rule v2: the live oscillation, its cause, the DAG replay, the fix behind a height switch",
|
||||
"short": "Difficulty rule v2"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Devnet v4 cut-over: generator v2, 2/3 floor, three nodes and a seed on a fresh chain",
|
||||
"short": "Devnet v4 live: generator v2, two-thirds floor, fresh chain"
|
||||
"text": "The observer stored nothing for 78 minutes, then 7,022 blocks in two minutes",
|
||||
"short": "The observer stored nothing for 78 minutes, then 7,022 blocks in two…"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "First hourly program swap on the live devnet: compile-ahead, no pause, two cards",
|
||||
"short": "First live hourly swap: no pause on Mac, NVIDIA or AMD"
|
||||
"text": "The RTX 5090 Windows rig at the 14:20 boundary: a worker stuck on the previous epoch",
|
||||
"short": "The RTX 5090 Windows rig at the 14"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"text": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine",
|
||||
"short": "Proving: devnet v4 shards on the Apple M5 Max CPU, loaded machine"
|
||||
"text": "Shard proving on the RTX 5090: a full shard compressed in 10.9 s, a two-shard block aggregated in 2.2 s, all verified",
|
||||
"short": "Shard layer on the RTX 5090: a full shard proven in 10.9 s, a block aggregated in 2.2 s"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,45 @@
|
|||
/* Igneum live steps (6 October 2026, the owner: "I liked it when we could see each step of the algo working").
|
||||
The original step view with real blocks: a lane chart where every square is a block the observer stored, released at a
|
||||
calm pace (the feed is sampled, the counters elsewhere read the chain), and every square shows its step: mined (an outline,
|
||||
grey while pending, ember once included), shards being proven (molten quarter cells fill as shards are verified or paid),
|
||||
proven (ember fill), locked checkpoint (a ring; a dashed "final" line marks that everything to its left is final).
|
||||
Each transition is animated as it happens (a glow and a ring pulse) and captioned in one line for the newest block that
|
||||
changed. The wheel is never touched: the page scrolls through the scene. Light and dark from the CSS tokens; a still frame
|
||||
under reduced motion. Needs /scenes/feed.js (IgneumFeed) on the page.
|
||||
Usage: var steps = IgneumSteps.mount(canvas, { caption: el, window: 300 }); then IgneumFeed.start({ subs: [steps], window: 300 }). */
|
||||
/* Igneum live steps (6 October 2026). The owner: "I liked it when we could see each step of the algo working", and later,
|
||||
"can we have a dag animation like we had before? with the shards making up the block, then the final line and the other
|
||||
bits that looked really cool but brought up to date?"
|
||||
The lane chart with the original bits, every one driven by the live feed: a real block arrives from the right with a glow
|
||||
and its chain number under it; its outline is grey while pending and ember once included; as its shards are verified or
|
||||
paid, particles fly in from the edges and its quarter cells fill molten; when every shard is proven the block turns ember
|
||||
and its provers are paid (the caption says to how many); a locked checkpoint takes a ring and a ripple and the dashed
|
||||
"final" line sweeps in from the right to it, drawn only while finality is active. A proof or a lock that lands on a block
|
||||
which has already left the screen re-enters from the right as its own event, so every step is seen when it happens. The
|
||||
stream is sampled (one block every 2.5 s, a crossing of about 34 s); the counters elsewhere read the chain. No wheel
|
||||
handler: the page scrolls through the scene. Light and dark from the CSS tokens; a still frame under reduced motion.
|
||||
Needs /scenes/feed.js (IgneumFeed) on the page.
|
||||
Usage: var steps = IgneumSteps.mount(canvas, { caption: el }); then IgneumFeed.start({ subs: [steps], window: 300 }). */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var F = root.IgneumFeed;
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function hms(ms) { var d = new Date(ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); }
|
||||
function fmt(n) { return n === null || n === undefined ? null : Number(n).toLocaleString('en-GB'); }
|
||||
function stepOf(b) {
|
||||
var sh = b.shards || [], done = function (x) { return x.state === 'verified' || x.state === 'paid'; };
|
||||
if (b.proven || (sh.length && sh.every(done))) return 'proven';
|
||||
if (sh.some(function (x) { return x.state === 'proving' || done(x); })) return 'proving';
|
||||
return 'mined';
|
||||
}
|
||||
function isDone(x) { return x.state === 'verified' || x.state === 'paid'; }
|
||||
function stepOf(b) { var sh = b.shards || []; if (b.proven || (sh.length && sh.every(isDone))) return 'proven'; if (sh.some(function (x) { return x.state === 'proving' || isDone(x); })) return 'proving'; return 'mined'; }
|
||||
function shardKey(b) { return (b.shards || []).map(function (x) { return x.state[0]; }).join(''); }
|
||||
function mount(canvas, opts) {
|
||||
opts = opts || {};
|
||||
var ctx = canvas.getContext('2d'), size = F.sizer(canvas, ctx), dims = size(), T = F.theme();
|
||||
var lanes = 5, S, GAP, speed, blocks = [], byHash = {}, queue = [], laneOf = {}, laneN = 0, last = 0, prevT = 0;
|
||||
// one block released every 2.5 s and a crossing of about 34 s at 1440: the chain merges a pending block in about 10 s, so a
|
||||
// square is still on screen when its step changes (the original pace of 10 s lost most transitions off the left edge)
|
||||
var RELEASE_MS = 2500;
|
||||
var caption = opts.caption || null, lastChange = 0, latest = null, finality = null, proving = null, serverNow = 0;
|
||||
var lanes = 5, S, GAP, speed, RELEASE_MS = 2500, blocks = [], byHash = {}, known = {}, queue = [], events = [], sparks = [], ripples = [], laneOf = {}, laneN = 0, last = 0, prevT = 0;
|
||||
var caption = opts.caption || null, lastChange = 0, latest = null, finality = null, proving = null, seeded = false, lockX = null, lockTarget = null, lockHash = null;
|
||||
function metrics() { S = dims.W < 600 ? 18 : 26; GAP = dims.W < 600 ? S * 2.5 : S * 3.1; speed = GAP / RELEASE_MS; }
|
||||
metrics();
|
||||
root.addEventListener('resize', function () { dims = size(); metrics(); L.kick(); });
|
||||
function laneY(l) { return dims.H * 0.14 + l * (dims.H * 0.72 / (lanes - 1)); }
|
||||
metrics(); root.addEventListener('resize', function () { dims = size(); metrics(); L.kick(); });
|
||||
function laneY(l) { return dims.H * 0.14 + l * (dims.H * 0.70 / (lanes - 1)); }
|
||||
function laneFor(m) { if (!(m in laneOf)) laneOf[m] = laneN++ % lanes; return laneOf[m]; }
|
||||
function cpIndexOf(hash) { var cps = (finality && finality.checkpoints) || []; for (var i = cps.length - 1; i >= 0; i--) if (cps[i].hash === hash) return cps[i].index; return null; }
|
||||
// the record of a block for the caption: its words with their times
|
||||
function provers(b) { var set = {}; (b.shards || []).forEach(function (x) { if (x.prover) set[x.prover] = 1; }); return Object.keys(set).length; }
|
||||
function describe(e) {
|
||||
var b = e.src, parts = [], name = b.number !== null && b.number !== undefined ? 'block ' + fmt(b.number) : 'block ' + b.hash.slice(0, 8);
|
||||
parts.push('mined ' + hms(b.ts) + (b.color === 'red' ? ', excluded' : b.chain ? ', on the selected chain' : b.color === 'blue' ? ', included' : ', pending'));
|
||||
var sh = b.shards || [];
|
||||
if (sh.length) {
|
||||
var proving = sh.filter(function (x) { return x.state === 'proving'; }), done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; });
|
||||
var provers = {}; sh.forEach(function (x) { if (x.prover) provers[x.prover] = 1; }); var np = Object.keys(provers).length;
|
||||
if (e.step === 'proven') { var lag = Math.max.apply(null, sh.map(function (x) { return x.lag || 0; })); parts.push(sh.length + (sh.length === 1 ? ' shard' : ' shards') + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : '')); parts.push('proven in ' + (lag > 0 ? lag : Math.max(1, Math.round((Date.now() - b.ts) / 1000))) + ' s'); }
|
||||
else if (proving.length || done.length) parts.push((done.length ? done.length + ' of ' + sh.length + ' shards proven' : sh.length + (sh.length === 1 ? ' shard' : ' shards') + ' proving') + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : ''));
|
||||
var done = sh.filter(isDone), np = provers(b), paid = sh.filter(function (x) { return x.state === 'paid'; }).length;
|
||||
if (e.step === 'proven') { var lag = Math.max.apply(null, sh.map(function (x) { return x.lag || 0; })); parts.push(sh.length + (sh.length === 1 ? ' shard' : ' shards') + ' proven in ' + (lag > 0 ? lag : Math.max(1, Math.round((Date.now() - b.ts) / 1000))) + ' s'); if (paid) parts.push(np ? np + (np === 1 ? ' prover paid' : ' provers paid') : 'provers paid'); }
|
||||
else if (done.length) parts.push(done.length + ' of ' + sh.length + ' shards proven' + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : ''));
|
||||
else if (sh.some(function (x) { return x.state === 'proving'; })) parts.push(sh.length + (sh.length === 1 ? ' shard' : ' shards') + ' proving' + (np ? ' on ' + np + (np === 1 ? ' card' : ' cards') : ''));
|
||||
else parts.push(sh.length + (sh.length === 1 ? ' shard planned' : ' shards planned'));
|
||||
}
|
||||
if (b.locked) { var idx = cpIndexOf(b.hash); parts.push('locked at checkpoint' + (idx !== null ? ' ' + fmt(idx) : '')); }
|
||||
|
|
@ -50,64 +47,75 @@
|
|||
return name + ': ' + parts.join('; ');
|
||||
}
|
||||
function setCaption(text, muted) { if (!caption) return; caption.textContent = text; caption.classList.toggle('muted', !!muted); }
|
||||
function apply(e, src, now, announce) {
|
||||
var before = e.step + '|' + e.src.color + '|' + e.src.locked + '|' + e.src.final + '|' + (e.src.chain ? 1 : 0);
|
||||
e.src = src; e.step = stepOf(src);
|
||||
var after = e.step + '|' + src.color + '|' + src.locked + '|' + src.final + '|' + (src.chain ? 1 : 0);
|
||||
if (announce && after !== before) { e.glow = 1; if (src.locked && !e.ringed) { e.ringed = true; e.ripple = 1; } lastChange = now; latest = e; setCaption(describe(e)); if (opts.onChange) opts.onChange(e, before, after); }
|
||||
function rr(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
|
||||
function cellOf(b, i) { var h = S / 2, q = S / 2 - 3; return { x: b.x - h + 3 + (i % 2) * q + (q - 1) / 2, y: b.y - h + 3 + Math.floor(i / 2) * q + (q - 1) / 2 }; }
|
||||
// a particle per newly proven shard, from an edge to the block's quarter cell (the original scene's sparks, now real)
|
||||
function sparkFor(e, i) { var fromTop = Math.random() < 0.5; sparks.push({ x: Math.random() * dims.W, y: fromTop ? -6 : dims.H + 6, b: e, i: i, p: 0 }); }
|
||||
function announce(e, now, reason) { e.glow = 1; lastChange = now; latest = e; setCaption(describe(e)); if (opts.onChange) opts.onChange(e, reason); }
|
||||
function apply(e, src, now) {
|
||||
var before = e.step + '|' + e.src.color + '|' + e.src.locked + '|' + (e.src.chain ? 1 : 0) + '|' + shardKey(e.src);
|
||||
var oldSh = e.src.shards || []; e.src = src; e.step = stepOf(src);
|
||||
var after = e.step + '|' + src.color + '|' + src.locked + '|' + (src.chain ? 1 : 0) + '|' + shardKey(src);
|
||||
if (after === before) return false;
|
||||
(src.shards || []).forEach(function (x, i) { if (i < 4 && isDone(x) && !(oldSh[i] && isDone(oldSh[i])) && e.x !== null) sparkFor(e, i); });
|
||||
if (src.locked && !e.ringed) { e.ringed = true; if (e.x !== null) { ripples.push({ x: e.x, y: e.y, r: S, a: 1 }); ripples.push({ x: e.x, y: e.y, r: S * 0.5, a: 1 }); } }
|
||||
announce(e, now, after.split('|')[0]); return true;
|
||||
}
|
||||
var seeded = false;
|
||||
function push(d) {
|
||||
var now = performance.now(); finality = d.finality || null; proving = d.proving || null; serverNow = new Date(d.now).getTime();
|
||||
if (!seeded) {
|
||||
// the first reply fills the scene: the newest blocks placed across the width, oldest at the left, so nothing starts empty
|
||||
seeded = true; var n = Math.ceil(dims.W / GAP) + 1, recent = d.blocks.slice(-n);
|
||||
recent.forEach(function (b, i) { var e = { hash: b.hash, src: b, step: stepOf(b), x: dims.W - S - (recent.length - 1 - i) * GAP, y: laneY(laneFor(b.miner || '?')), glow: 0, ripple: 0, ringed: !!b.locked, born: now }; byHash[b.hash] = e; blocks.push(e); });
|
||||
}
|
||||
var now = performance.now(); finality = d.finality || null; proving = d.proving || null;
|
||||
if (finality && !finality.active) { lockTarget = null; lockX = null; lockHash = null; }
|
||||
if (!seeded) { seeded = true; var n = Math.ceil(dims.W / GAP) + 1, recent = d.blocks.slice(-n);
|
||||
recent.forEach(function (b, i) { var e = { hash: b.hash, src: b, step: stepOf(b), x: dims.W - S - (recent.length - 1 - i) * GAP, y: laneY(laneFor(b.miner || '?')), glow: 0, ringed: !!b.locked, born: now }; byHash[b.hash] = e; known[b.hash] = e; blocks.push(e); }); }
|
||||
d.blocks.forEach(function (b) {
|
||||
var e = byHash[b.hash];
|
||||
if (e) { apply(e, b, now, true); return; }
|
||||
e = { hash: b.hash, src: b, step: stepOf(b), x: null, y: null, glow: 0, ripple: 0, ringed: !!b.locked, born: 0 };
|
||||
byHash[b.hash] = e; queue.push(e); if (queue.length > 6) { var dropped = queue.shift(); delete byHash[dropped.hash]; }
|
||||
var e = known[b.hash];
|
||||
if (e) { var changed = apply(e, b, now); if (changed && e.x === null && blocks.indexOf(e) < 0 && (e.step === 'proven' || b.locked)) { e.event = true; queue.push(e); } return; }
|
||||
e = { hash: b.hash, src: b, step: stepOf(b), x: null, y: null, glow: 0, ringed: !!b.locked, born: 0 }; known[b.hash] = e; queue.push(e);
|
||||
});
|
||||
var seen = {}; d.blocks.forEach(function (b) { seen[b.hash] = 1; });
|
||||
// a block the feed no longer carries keeps its last words on screen until it leaves at the left
|
||||
// forget blocks that have left both the feed and the screen
|
||||
var inFeed = {}; d.blocks.forEach(function (b) { inFeed[b.hash] = 1; });
|
||||
Object.keys(known).forEach(function (h) { var e = known[h]; if (!inFeed[h] && blocks.indexOf(e) < 0 && queue.indexOf(e) < 0) delete known[h]; });
|
||||
if (queue.length > 12) queue = queue.filter(function (e) { return e.event; }).concat(queue.filter(function (e) { return !e.event; }).slice(-6));
|
||||
if (!latest || now - lastChange > 60000) {
|
||||
var why = finality && finality.supported && !finality.active ? 'finality paused: under two thirds of the weight is signing, so no checkpoint locks' : proving && proving.supported && proving.active && !(proving.shards_paid_10m > 0) ? 'no proof landed in the last 10 minutes; proofs land about ' + (proving.median_proof_lag_s ? Math.round(proving.median_proof_lag_s) : 380) + ' s behind the tip' : 'no block changed step in the last minute';
|
||||
setCaption('Blocks arrive every second. ' + why.charAt(0).toUpperCase() + why.slice(1) + '.', true);
|
||||
}
|
||||
L.kick();
|
||||
}
|
||||
function rr(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
|
||||
function spawn(e, t) { e.x = dims.W + S; e.y = laneY(laneFor(e.src.miner || '?')); e.born = t; blocks.push(e); if (blocks.length > 80) { var old = blocks.shift(); if (!queue.some(function (q) { return q === old; })) delete byHash[old.hash]; } }
|
||||
function spawn(e, t) { e.x = dims.W + S; e.y = laneY(laneFor(e.src.miner || '?')); e.born = t; e.glow = 1; blocks.push(e); byHash[e.hash] = e;
|
||||
if (e.event) { (e.src.shards || []).forEach(function (x, i) { if (i < 4 && isDone(x)) sparkFor(e, i); }); if (e.src.locked) { ripples.push({ x: e.x, y: e.y, r: S, a: 1 }); } }
|
||||
if (blocks.length > 80) { var old = blocks.shift(); delete byHash[old.hash]; } }
|
||||
function draw(t) {
|
||||
var W = dims.W, H = dims.H; T = F.theme();
|
||||
var dt = prevT ? Math.min(50, t - prevT) : 16; prevT = t;
|
||||
if (queue.length && t - last > RELEASE_MS) { spawn(queue.pop(), t); queue.length = 0; last = t; } // the newest waiting block; the rest are sampled out
|
||||
blocks.forEach(function (b) { b.x -= speed * dt; if (b.glow > 0) b.glow -= 0.02; if (b.ripple > 0) b.ripple -= 0.012; });
|
||||
blocks = blocks.filter(function (b) { if (b.x < -S * 2) { if (byHash[b.hash] === b && queue.indexOf(b) < 0) delete byHash[b.hash]; return false; } return true; });
|
||||
if (queue.length && t - last > RELEASE_MS) { var ev = queue.findIndex(function (e) { return e.event; }); spawn(ev >= 0 ? queue.splice(ev, 1)[0] : queue.pop(), t); if (ev < 0) queue = queue.filter(function (e) { return e.event; }); last = t; }
|
||||
blocks.forEach(function (b) { b.x -= speed * dt; if (b.glow > 0) b.glow -= 0.015; });
|
||||
blocks = blocks.filter(function (b) { if (b.x < -S * 2) { b.x = null; delete byHash[b.hash]; return false; } return true; });
|
||||
sparks.forEach(function (sp) { sp.p += 0.035; }); sparks = sparks.filter(function (sp) { return sp.p < 1 && blocks.indexOf(sp.b) >= 0; });
|
||||
ripples.forEach(function (r) { r.r += 2.2; r.a -= 0.012; r.x -= speed * dt; }); ripples = ripples.filter(function (r) { return r.a > 0; });
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.strokeStyle = F.rgba(T.line, 0.9); ctx.lineWidth = 1; for (var l = 0; l < lanes; l++) { ctx.beginPath(); ctx.moveTo(0, laneY(l)); ctx.lineTo(W, laneY(l)); ctx.stroke(); }
|
||||
// parent edges between blocks on screen
|
||||
blocks.forEach(function (b) { (b.src.parents || []).forEach(function (h) { var q = byHash[h]; if (!q || q.x === null || blocks.indexOf(q) < 0) return; var both = b.step === 'proven' && q.step === 'proven'; ctx.strokeStyle = both ? F.rgba(T.ember, 0.45) : F.rgba(T.ash, 0.22); ctx.lineWidth = both ? 1.5 : 1; ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + q.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, q.y, q.x, q.y); ctx.stroke(); }); });
|
||||
// the lock line: everything left of the newest locked block is final
|
||||
// the lock line and the word final are drawn only while finality is active (the owner's rule: no "final" anywhere while finality is paused)
|
||||
var lk = null; if (!finality || finality.active) blocks.forEach(function (b) { if (b.src.locked && (!lk || b.x > lk.x)) lk = b; });
|
||||
if (lk) { ctx.strokeStyle = F.rgba(T.ember, 0.55); ctx.lineWidth = 1.5; ctx.setLineDash([5, 6]); ctx.beginPath(); ctx.moveTo(lk.x, 6); ctx.lineTo(lk.x, H - 6); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = T.ash; ctx.font = '500 ' + Math.max(10, Math.round(S * 0.42)) + 'px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic'; ctx.fillText('final', lk.x - 8, 14); }
|
||||
blocks.forEach(function (b) { (b.src.parents || []).forEach(function (h) { var q = byHash[h]; if (!q || q.x === null) return; var both = b.step === 'proven' && q.step === 'proven'; ctx.strokeStyle = both ? F.rgba(T.ember, 0.45) : F.rgba(T.ash, 0.22); ctx.lineWidth = both ? 1.5 : 1; ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + q.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, q.y, q.x, q.y); ctx.stroke(); }); });
|
||||
// the final line: drawn only while finality is active; it sweeps in from the right to the newest locked block on screen
|
||||
if (finality && finality.active) { var lk = null; blocks.forEach(function (b) { if (b.src.locked && (!lk || b.x > lk.x)) lk = b; });
|
||||
if (lk) { if (lockHash !== lk.hash) { lockHash = lk.hash; if (lockX === null) lockX = W + 20; } lockTarget = lk.x; lockX += (lockTarget - lockX) * 0.08;
|
||||
ctx.strokeStyle = F.rgba(T.ember, 0.55); ctx.lineWidth = 1.5; ctx.setLineDash([5, 6]); ctx.beginPath(); ctx.moveTo(lockX, 6); ctx.lineTo(lockX, H - 6); ctx.stroke(); ctx.setLineDash([]);
|
||||
ctx.fillStyle = T.ash; ctx.font = '500 ' + Math.max(10, Math.round(S * 0.42)) + 'px IBM Plex Mono, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'alphabetic'; ctx.fillText('final', lockX - 8, 14); } }
|
||||
blocks.forEach(function (b) {
|
||||
var h = S / 2, w = b.step, src = b.src, ex = src.color === 'red';
|
||||
if (b.glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, S * 1.6); g.addColorStop(0, F.rgba(T.ember, 0.45 * b.glow)); g.addColorStop(1, F.rgba(T.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, S * 1.6, 0, Math.PI * 2); ctx.fill(); }
|
||||
if (ex) ctx.globalAlpha = 0.45;
|
||||
if (w === 'proven') { ctx.fillStyle = T.ember; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.fill(); }
|
||||
else {
|
||||
ctx.fillStyle = T.row; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.fill();
|
||||
else { ctx.fillStyle = T.row; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.fill();
|
||||
ctx.strokeStyle = w === 'proving' ? F.rgba(T.molten, 0.9) : (src.chain || src.color === 'blue') ? F.rgba(T.ember, 0.8) : F.rgba(T.ash, 0.7); ctx.lineWidth = 2; rr(b.x - h, b.y - h, S, S, S * 0.24); ctx.stroke();
|
||||
if (w === 'proving') { var sh = src.shards || [], n = Math.min(4, sh.length), q = S / 2 - 3; for (var i = 0; i < n; i++) { var st = sh[i].state; if (st === 'verified' || st === 'paid' || st === 'proving') { ctx.fillStyle = st === 'proving' ? F.rgba(T.molten, 0.45) : T.molten; var qx = b.x - h + 3 + (i % 2) * q, qy = b.y - h + 3 + Math.floor(i / 2) * q; rr(qx, qy, q - 1, q - 1, 2); ctx.fill(); } } }
|
||||
}
|
||||
var sh = src.shards || [], n = Math.min(4, sh.length), q = S / 2 - 3;
|
||||
for (var i = 0; i < n; i++) { var st = sh[i].state; if (st === 'verified' || st === 'paid' || st === 'proving') { ctx.fillStyle = st === 'proving' ? F.rgba(T.molten, 0.45) : T.molten; var qx = b.x - h + 3 + (i % 2) * q, qy = b.y - h + 3 + Math.floor(i / 2) * q; rr(qx, qy, q - 1, q - 1, 2); ctx.fill(); } } }
|
||||
if (src.locked) { var pulse = 0.8 + 0.2 * Math.sin(t / 350); ctx.strokeStyle = F.rgba(T.ember, pulse); ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(b.x, b.y, S * 1.15, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = T.bone; ctx.beginPath(); ctx.arc(b.x, b.y, S * 0.16, 0, Math.PI * 2); ctx.fill(); }
|
||||
if (b.ripple > 0) { ctx.strokeStyle = F.rgba(T.ember, b.ripple); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, S * (1.3 + (1 - b.ripple) * 2), 0, Math.PI * 2); ctx.stroke(); }
|
||||
// the chain number under the block (the short hash off the chain), at the wide size only
|
||||
if (S >= 26) { ctx.fillStyle = F.rgba(T.ash, ex ? 0.6 : 0.9); ctx.font = '400 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(src.number !== null && src.number !== undefined ? fmt(src.number) : src.hash.slice(0, 6), b.x, b.y + h + 5); }
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
sparks.forEach(function (sp) { var c = cellOf(sp.b, sp.i), e = 1 - Math.pow(1 - sp.p, 3), sx = sp.x + (c.x - sp.x) * e, sy = sp.y + (c.y - sp.y) * e; ctx.strokeStyle = F.rgba(T.molten, 0.5 * (1 - sp.p)); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(sp.x + (c.x - sp.x) * Math.max(0, e - 0.15), sp.y + (c.y - sp.y) * Math.max(0, e - 0.15)); ctx.lineTo(sx, sy); ctx.stroke(); ctx.fillStyle = T.molten; ctx.beginPath(); ctx.arc(sx, sy, 2.2, 0, Math.PI * 2); ctx.fill(); });
|
||||
ripples.forEach(function (r) { ctx.strokeStyle = F.rgba(T.ember, r.a); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2); ctx.stroke(); });
|
||||
}
|
||||
var L = F.loop(draw, canvas); L.kick();
|
||||
return { push: push, redraw: L.kick };
|
||||
|
|
|
|||
14
tools/ci/export-exclude.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Paths under the public export list that are NOT exported: research and operations documents written for the
|
||||
# team, not for the public spec mirror (one path per line, relative to the repository root; a directory excludes its
|
||||
# tree; # comments ignored). Read by tools/ci/identity-check.sh (pruned from the export copy before the grep) and by
|
||||
# igneum-public/tools/sync.sh (pruned from the mirror after the copy), so the two can never disagree.
|
||||
#
|
||||
# Rule (CLAUDE.md, "CI red is stop-the-line", 6 October 2026): a research document that reviews internal operations or
|
||||
# quotes the identity patterns as text lives here, outside the export list. A document that IS meant for the mirror is
|
||||
# not listed here and must pass the identity grep like everything else the public reads.
|
||||
#
|
||||
# 6 October 2026, 21:2x UK: the Horizon lane's polish review (lane 6) quotes the overlay network's product name, the
|
||||
# intake key variable name and the identity guard's own regexes as text; it blocked every master run from 20:23Z.
|
||||
docs/analysis/horizon/polish.md
|
||||
# the CI failure classification of 6 October 2026 (an operations document: run ids, step names, the fixes)
|
||||
docs/analysis/ci-failures-2026-10-06.md
|
||||
|
|
@ -8,10 +8,35 @@
|
|||
# The private rules of the mirror (sync.local.sed, identity.local) are not here; they run at export time.
|
||||
#
|
||||
# tools/ci/identity-check.sh # exit 1 on any hit, with file:line
|
||||
# tools/ci/identity-check.sh --self-test # a forbidden word in an exported path fails; the same word in an excluded path passes
|
||||
#
|
||||
# Excluded from the export (6 October 2026): the paths in tools/ci/export-exclude.txt, research and operations documents
|
||||
# written for the team. igneum-public/tools/sync.sh prunes the same file after its copy, so what this check skips never
|
||||
# reaches the mirror either. IDENTITY_CHECK_REPO points the check at another tree (the self-test's fixture).
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO="$(cd "$HERE/../.." && pwd)"
|
||||
REPO="${IDENTITY_CHECK_REPO:-$(cd "$HERE/../.." && pwd)}"
|
||||
PATTERNS="$HERE/forbidden-strings.txt"
|
||||
EXCLUDE="$HERE/export-exclude.txt"
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
fx="$(mktemp -d)"; trap 'rm -rf "$fx"' EXIT
|
||||
mkdir -p "$fx/docs/analysis/horizon" "$fx/docs/spec" "$fx/site"
|
||||
echo "# spec" > "$fx/docs/spec/clean.md"
|
||||
# the excluded path (the first entry of export-exclude.txt) carrying a forbidden word, and a clean exported tree: must pass
|
||||
first="$(grep -vE '^\s*(#|$)' "$EXCLUDE" | head -1)"
|
||||
mkdir -p "$fx/$(dirname "$first")"; printf 'quotes the overlay name: Tailscale, and the key: LOG_INTAKE\n' > "$fx/$first"
|
||||
if ! IDENTITY_CHECK_REPO="$fx" bash "$0" >/dev/null 2>&1; then echo "self-test failed: a forbidden word in the excluded path $first was reported"; exit 1; fi
|
||||
# the same word in an exported path: must fail
|
||||
printf 'the overlay name: Tailscale\n' > "$fx/docs/spec/leak.md"
|
||||
if IDENTITY_CHECK_REPO="$fx" bash "$0" >/dev/null 2>&1; then echo "self-test failed: a forbidden word in docs/spec/leak.md passed"; exit 1; fi
|
||||
rm -f "$fx/docs/spec/leak.md"
|
||||
# a served site file carrying a pattern: must fail, unscrubbed
|
||||
printf '<p>a home path /Users/someone/x</p>\n' > "$fx/site/leak.html"
|
||||
if IDENTITY_CHECK_REPO="$fx" bash "$0" >/dev/null 2>&1; then echo "self-test failed: a home path in site/leak.html passed"; exit 1; fi
|
||||
echo "self-test passed: the excluded research path may quote the patterns; an exported document and a served page may not"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The export list of igneum-public/tools/sync.sh (keep in step with it), plus the two files published with the repository
|
||||
# at the public testnet (decision of 5 October 2026: the criticism ledger and its fixes file). They are not in sync.sh,
|
||||
|
|
@ -25,7 +50,11 @@ FILES=(site/ledger.html docs/provenance.md docs/bench-log.md docs/evidence.md do
|
|||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
for d in "${DIRS[@]}"; do [ -d "$REPO/$d" ] && { mkdir -p "$TMP/$(dirname "$d")"; cp -R "$REPO/$d" "$TMP/$d"; }; done
|
||||
for f in "${FILES[@]}"; do [ -f "$REPO/$f" ] && { mkdir -p "$TMP/$(dirname "$f")"; cp "$REPO/$f" "$TMP/$f"; }; done
|
||||
# prune what sync.sh prunes
|
||||
# prune what sync.sh prunes: the excluded research paths first (tools/ci/export-exclude.txt), then build output
|
||||
while IFS= read -r x; do
|
||||
x="${x%%#*}"; x="$(printf '%s' "$x" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')"; [ -n "$x" ] || continue
|
||||
rm -rf "${TMP:?}/$x"
|
||||
done < "$EXCLUDE"
|
||||
find "$TMP" \( -name target -o -name __pycache__ -o -name out -o -name 'build-*' -o -name node_modules -o -name results -o -name runs \) -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$TMP" \( -name .DS_Store -o -name '*.pyc' \) -type f -delete
|
||||
|
||||
|
|
@ -77,4 +106,4 @@ if [ -n "$SITE_HITS" ]; then
|
|||
printf '%s\n' "$SITE_HITS" | sed "s#^$REPO/##" | cut -c1-200
|
||||
exit 1
|
||||
fi
|
||||
echo "identity grep: 0 hits over $(printf '%s\n' "$TEXT_FILES" | grep -c .) export files and $(printf '%s\n' "$SITE_FILES" | grep -c .) served site files"
|
||||
echo "identity grep: 0 hits over $(printf '%s\n' "$TEXT_FILES" | grep -c .) export files ($(grep -cvE '^\s*(#|$)' "$EXCLUDE") research paths excluded by tools/ci/export-exclude.txt) and $(printf '%s\n' "$SITE_FILES" | grep -c .) served site files"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# Installs the repository's git hooks into this checkout (pre-push: no conflict markers, the site builds). Linked
|
||||
# worktrees share the main repository's .git/hooks, so one install covers every worktree.
|
||||
# The hook is READ-ONLY (6 October 2026): it builds the site in a temporary copy and never writes a tracked file in the
|
||||
# worktree. Before this, `node build.mjs` in site/ rewrote downloads.json, journey.json, bench.html and the stamped pages
|
||||
# in whatever worktree the push ran from (five worktrees carried 0.3.14's rows as uncommitted edits, and generated pages
|
||||
# kept changing under agents after 063bbca). Only the ship step writes, with SITE_DOWNLOADS_REFRESH=1 node site/build.mjs.
|
||||
# Installs the repository's git hooks into this checkout's shared hooks directory (one set for the main checkout and
|
||||
# every worktree, since worktrees share .git/hooks). Each hook is a two-line delegate to a versioned script, so a
|
||||
# change to the gate is a commit, never a reinstall:
|
||||
# pre-commit -> tools/ci/windows-paths-check.sh --staged (a path Windows cannot check out never enters a commit)
|
||||
# pre-push -> tools/ci/pre-push.sh --hook (the full CI gate before a push to master or release-*,
|
||||
# the two structural checks before any other push)
|
||||
# Neither hook writes into the worktree (the site is built in a temporary copy; 063bbca, 6 October 2026).
|
||||
# A tree that predates the scripts (an old branch) falls back to the conflict-marker check alone.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
HOOKS="$(git rev-parse --git-common-dir)/hooks"
|
||||
mkdir -p "$HOOKS"
|
||||
cat > "$HOOKS/pre-push" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
bash tools/ci/no-conflict-markers.sh
|
||||
# the site build, in a temporary copy: a failing build blocks the push; nothing in the worktree is written
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
cp -R site "$tmp/site"
|
||||
(cd "$tmp/site" && SITE_DOWNLOADS_OFFLINE=1 node build.mjs >/dev/null) || { echo "pre-push: the site build fails; fix it before pushing" >&2; exit 1; }
|
||||
hooks="$(git rev-parse --git-common-dir)/hooks"; mkdir -p "$hooks"
|
||||
cat > "$hooks/pre-push" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
# installed by tools/ci/install-hooks.sh; the gate itself is versioned in tools/ci/pre-push.sh (same script as CI)
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
if [ -f tools/ci/pre-push.sh ]; then exec bash tools/ci/pre-push.sh --hook "$@"; fi
|
||||
exec bash tools/ci/no-conflict-markers.sh
|
||||
HOOK
|
||||
chmod +x "$HOOKS/pre-push"; echo "pre-push hook installed at $HOOKS/pre-push (read-only site build)"
|
||||
cat > "$hooks/pre-commit" <<'HOOK'
|
||||
#!/usr/bin/env bash
|
||||
# installed by tools/ci/install-hooks.sh; the check itself is versioned in tools/ci/windows-paths-check.sh
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
[ -f tools/ci/windows-paths-check.sh ] || exit 0
|
||||
exec bash tools/ci/windows-paths-check.sh --staged
|
||||
HOOK
|
||||
chmod +x "$hooks/pre-push" "$hooks/pre-commit"
|
||||
echo "hooks installed in $hooks: pre-commit (Windows paths of the staged files), pre-push (tools/ci/pre-push.sh --hook)"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ const REQUIRED = {
|
|||
['E5', 'The one payment to the project is the Ember software\u2019s optional 1% dev fee', 'The one payment to the project is the Ember software\'s optional 1% dev fee'],
|
||||
['X2', 'Public testnet: not yet open; the devnet build is here for people who want to look'],
|
||||
['X7', 'hello@igneum.network'],
|
||||
// restored with the home page on 6 October 2026 (the owner: "revert it but polish it")
|
||||
['G4', 'admin keys in consensus'],
|
||||
['C2', 'since 2019 (approximate)'],
|
||||
['X8', 'No listing is arranged, promised or sought by the project'],
|
||||
['X31', 'The public testnet is weeks away: three seed nodes and the public RPC are up, and it opens when the go checklist closes.'],
|
||||
],
|
||||
'litepaper.html': [
|
||||
['X3', 'Live rows arrive with the public testnet.'],
|
||||
|
|
|
|||
|
|
@ -35,15 +35,9 @@ if [ "${1:-}" = "--self-test" ]; then
|
|||
echo "self-test passed: a Projects path fails, a worktree-list loop with a write fails, an own-toplevel write passes"; exit 0
|
||||
fi
|
||||
fail=0
|
||||
# a git hook never writes a tracked file: the hook body in tools/ci/install-hooks.sh (between <<'HOOK' and HOOK) may run the
|
||||
# site build only inside a mktemp copy (6 October 2026: the in-place build rewrote generated pages in other worktrees)
|
||||
if [ -f tools/ci/install-hooks.sh ]; then
|
||||
body="$(sed -n "/<<'HOOK'/,/^HOOK$/p" tools/ci/install-hooks.sh)"
|
||||
if printf '%s' "$body" | grep -qE "build\.mjs" && ! printf '%s' "$body" | grep -qE "mktemp"; then
|
||||
echo "foreign-tree: the pre-push hook runs the site build in the worktree (no mktemp copy): a hook never writes a tracked file"; fail=1
|
||||
fi
|
||||
fi
|
||||
while IFS= read -r f; do grep -nE "$WARN" "$f" | grep -vE '^[0-9]+:\s*(#|//)' | sed "s|^|foreign-tree (warning, env-only default owed): $f:|" | cut -c1-200; done < <(git ls-files 'tools/**' 'packaging/**' 'infra/**' 'relay/**' | grep -E '\.(sh|mjs|js|py|ps1)$' | grep -v '^tools/ci/no-foreign-tree-writes.sh$')
|
||||
# `|| true`: with pipefail a file without a warning hit fails this pipeline, and set -e then ends the script silently with
|
||||
# exit 1 (bash 3.2 on the Mac; the two CI runs right after this check landed on 6 October 2026 died the same way)
|
||||
while IFS= read -r f; do grep -nE "$WARN" "$f" | grep -vE '^[0-9]+:\s*(#|//)' | sed "s|^|foreign-tree (warning, env-only default owed): $f:|" | cut -c1-200 || true; done < <(git ls-files 'tools/**' 'packaging/**' 'infra/**' 'relay/**' | grep -E '\.(sh|mjs|js|py|ps1)$' | grep -v '^tools/ci/no-foreign-tree-writes.sh$')
|
||||
while IFS= read -r f; do check_file "$f" || fail=1; done < <(git ls-files 'tools/**' 'packaging/**' 'site/*.mjs' 'infra/**' 'relay/**' | grep -E '\.(sh|mjs|js|py|ps1)$' | grep -v '^tools/ci/no-foreign-tree-writes.sh$')
|
||||
[ "$fail" = 0 ] && echo "foreign-tree: every script writes under its own toplevel"
|
||||
exit $fail
|
||||
|
|
|
|||
133
tools/ci/pre-push.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env bash
|
||||
# The one gate. Every fast tree check CI runs, in one script, so the local gate and CI can never drift: the `site` job
|
||||
# of .github/workflows/ci.yml calls `tools/ci/pre-push.sh --ci`, and the pre-push hook (tools/ci/install-hooks.sh) calls
|
||||
# `tools/ci/pre-push.sh --hook` before any push to master or a release-* branch and refuses the push on red.
|
||||
#
|
||||
# tools/ci/pre-push.sh # the full gate over this working tree (any worktree; reads the tree, writes nothing in it)
|
||||
# tools/ci/pre-push.sh --ci # the same, with the site built in place (a CI checkout is disposable)
|
||||
# tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*, the two structural
|
||||
# # checks (conflict markers, Windows paths) for every other ref
|
||||
# tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, and the hook picks the
|
||||
# # right gate from the ref lines
|
||||
# tools/ci/pre-push.sh --list # the check names, one per line
|
||||
#
|
||||
# What is NOT here, on purpose: the three compile-or-compute jobs (igneum-pow tests, igneum-census, the simulators) and
|
||||
# the live public API check (master only, a network call), which stay separate steps in ci.yml. Everything here finished
|
||||
# in under 25 s on the Mac on 6 October 2026 (no-secrets 10 s, everything else under 3 s each).
|
||||
#
|
||||
# Local mode never writes into the worktree: the site is built in a temporary copy with SITE_DOWNLOADS_OFFLINE=1
|
||||
# (063bbca, 6 October 2026: a hook that built in place rewrote the downloads snapshot in five worktrees). The link, ledger
|
||||
# and identity checks then read the committed pages; CI builds in place and checks the rebuilt pages, the one difference.
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)" || exit 1
|
||||
# git hands a hook its own repository through the environment (GIT_DIR, GIT_INDEX_FILE, GIT_PREFIX, ...). Left in place,
|
||||
# every nested git inside the self-tests (remote-run.sh builds a mirror and pushes into it) would act on THIS repository
|
||||
# and fire this hook again inside the fixture: the first master push through the gate died that way (6 October 2026).
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_QUARANTINE_PATH GIT_PUSH_OPTION_COUNT
|
||||
MODE="${1:-local}"; MODE="${MODE#--}"
|
||||
RED=0; N=0; LOG="$(mktemp)"; trap 'rm -rf "$LOG" "${SITE_TMP:-}"' EXIT
|
||||
T0=$(date +%s)
|
||||
|
||||
run() {
|
||||
# run <name> <command...>: one line per check; the output of a red check is shown in full
|
||||
local name="$1"; shift; N=$((N + 1))
|
||||
local s=$(date +%s)
|
||||
if "$@" >"$LOG" 2>&1; then
|
||||
printf ' ok %3ds %s\n' "$(( $(date +%s) - s ))" "$name"
|
||||
else
|
||||
printf ' RED %3ds %s\n' "$(( $(date +%s) - s ))" "$name"; sed 's/^/ /' "$LOG" | cut -c1-240; RED=1
|
||||
fi
|
||||
}
|
||||
run_quiet() { "$@" >/dev/null 2>&1; }
|
||||
|
||||
site_build() {
|
||||
if [ "$MODE" = ci ]; then node site/build.mjs; return; fi
|
||||
SITE_TMP="$(mktemp -d)"; cp -R site "$SITE_TMP/site"
|
||||
(cd "$SITE_TMP/site" && SITE_DOWNLOADS_OFFLINE=1 node build.mjs)
|
||||
}
|
||||
|
||||
structural_checks() {
|
||||
run "no conflict markers in tracked files" bash tools/ci/no-conflict-markers.sh
|
||||
run "every tracked path is valid on Windows (colon, trailing dot, reserved names, length)" bash tools/ci/windows-paths-check.sh
|
||||
}
|
||||
|
||||
tree_checks() {
|
||||
run "site build (in a temporary copy locally, in place in CI)" site_build
|
||||
run "internal link check of site/*.html" node tools/ci/link-check.mjs
|
||||
run "ledger sentences present verbatim on their public pages" node tools/ci/ledger-text-check.mjs
|
||||
run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh
|
||||
run "shell inside .github/workflows parses (bash -n, the PowerShell 5.1 rule)" node tools/ci/check-workflow-shell.mjs
|
||||
run "PowerShell drive-reference check (\$name: in a double-quoted string)" bash tools/ci/ps-drive-ref-check.sh
|
||||
run "copied sources are re-stamped before a build" bash tools/ci/copied-sources-check.sh
|
||||
run "override params files parse with no duplicate key" bash tools/ci/override-json-check.sh
|
||||
run "second-engine playbooks log to a file and end their tree (C35)" bash tools/ci/second-engine-check.sh
|
||||
run "no playbook quits, pauses or resumes the installed app" bash -c 'bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh'
|
||||
run "no script writes into another worktree or walks Projects" bash -c 'bash tools/ci/no-foreign-tree-writes.sh --self-test && bash tools/ci/no-foreign-tree-writes.sh'
|
||||
run "the signer is never piped into head" bash tools/ci/signer-pipe-check.sh
|
||||
run "bash bodies in PowerShell job scripts pass bash -n" bash -c 'bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh'
|
||||
run "run jobs test their fetched kit before use" bash -c 'bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh'
|
||||
run "every Windows spawn of the app runs with a hidden console" bash -c 'node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs'
|
||||
run "pinned guest programs match their manifest" bash tools/ci/pinned-guests-check.sh
|
||||
run "root prover playbooks kill the GPU server and unlink its socket" bash tools/ci/prover-socket-check.sh
|
||||
run "commit-string gate self-test" bash tools/ci/commit-string-check.sh --self-test
|
||||
run "build server remote checkout self-test" bash infra/build-server/remote-run.sh --self-test
|
||||
run "no shell assignment hides behind a trailing comment (the swallowed-defaults class)" bash -c 'bash tools/ci/defaults-line-check.sh --self-test && bash tools/ci/defaults-line-check.sh'
|
||||
run "the identity check's own self-test (excluded research path passes, exported leak fails)" bash tools/ci/identity-check.sh --self-test
|
||||
run "the Windows paths check's own self-test" bash tools/ci/windows-paths-check.sh --self-test
|
||||
run "the red watcher's own self-test (one line per run, posted once)" node tools/ci/red-watch.mjs --self-test
|
||||
run "faucet unit tests" node --test site/api/faucet.test.mjs
|
||||
run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
run "ship tool self-test" node tools/ship-app.mjs --self-test
|
||||
run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs
|
||||
run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs
|
||||
run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh'
|
||||
}
|
||||
|
||||
gated_refs() {
|
||||
# stdin: the pre-push hook's lines "<local ref> <local sha> <remote ref> <remote sha>". Prints "full" when any remote
|
||||
# ref is master or release-*, else "light".
|
||||
local lref lsha rref rsha full=0
|
||||
while read -r lref lsha rref rsha; do
|
||||
case "$rref" in refs/heads/master|refs/heads/release-*) full=1 ;; esac
|
||||
done
|
||||
[ "$full" = 1 ] && echo full || echo light
|
||||
}
|
||||
|
||||
finish() {
|
||||
local what="$1" secs=$(( $(date +%s) - T0 ))
|
||||
if [ "$RED" = 0 ]; then echo "pre-push gate ($what): GREEN, $N checks in ${secs}s"; exit 0; fi
|
||||
echo "pre-push gate ($what): RED after $N checks in ${secs}s. Fix it before pushing (every check above is one CI runs; the same script runs there)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
self-test)
|
||||
fails=0
|
||||
st="$(mktemp)" # run in this shell, not a $(...) subshell, so RED is visible here
|
||||
run "known failure" false >"$st" 2>&1; out="$(cat "$st")"; case "$out" in *"RED"*"known failure"*) ;; *) echo "self-test failed: a failing check was not reported RED"; fails=1 ;; esac
|
||||
[ "$RED" = 1 ] || { echo "self-test failed: a failing check did not set RED"; fails=1; }
|
||||
RED=0
|
||||
run "known success" true >"$st" 2>&1; out="$(cat "$st")"; rm -f "$st"; case "$out" in *"ok"*"known success"*) ;; *) echo "self-test failed: a passing check was not reported ok"; fails=1 ;; esac
|
||||
[ "$RED" = 0 ] || { echo "self-test failed: a passing check set RED"; fails=1; }
|
||||
[ "$(printf 'refs/heads/x 1 refs/heads/master 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to master did not select the full gate"; fails=1; }
|
||||
[ "$(printf 'refs/heads/x 1 refs/heads/release-0.3.15 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to release-0.3.15 did not select the full gate"; fails=1; }
|
||||
[ "$(printf 'refs/heads/x 1 refs/heads/x 2\n' | gated_refs)" = light ] || { echo "self-test failed: a push to a feature branch selected the full gate"; fails=1; }
|
||||
[ "$(printf '' | gated_refs)" = light ] || { echo "self-test failed: a push with no refs selected the full gate"; fails=1; }
|
||||
[ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one"
|
||||
exit $fails ;;
|
||||
list)
|
||||
grep -E '^\s+run "' "$0" | sed -E 's/^\s+run "([^"]+)".*/\1/' ;;
|
||||
hook)
|
||||
which="$(gated_refs)"
|
||||
if [ "$which" = full ]; then
|
||||
echo "pre-push gate: a push to master or release-*, the full gate (the same checks CI runs):"
|
||||
structural_checks; tree_checks; finish "push to master or release-*"
|
||||
else
|
||||
echo "pre-push gate: a feature branch, the two structural checks:"
|
||||
structural_checks; finish "feature branch"
|
||||
fi ;;
|
||||
ci|local)
|
||||
[ "$MODE" = ci ] && echo "pre-push gate in CI (the same script as the local hook):" || echo "pre-push gate over this working tree:"
|
||||
structural_checks; tree_checks; finish "$MODE" ;;
|
||||
*) echo "usage: tools/ci/pre-push.sh [--ci|--hook|--self-test|--list]" >&2; exit 2 ;;
|
||||
esac
|
||||
301
tools/ci/red-watch.mjs
Executable file
|
|
@ -0,0 +1,301 @@
|
|||
#!/usr/bin/env node
|
||||
// The red-master watcher. One line per failed master or release-* run, so nobody opens the Actions page to learn CI is red.
|
||||
// Node 22, standard library only.
|
||||
//
|
||||
// node tools/ci/red-watch.mjs record --file <red.jsonl> in the workflow's `red` job (runs on igneum-build-1 after a
|
||||
// failed run): reads the run from the GitHub environment and
|
||||
// the failed jobs and steps from the API with the job's own
|
||||
// token, appends ONE JSON line for this run id (idempotent)
|
||||
// node tools/ci/red-watch.mjs post --file <red.jsonl> [--live] on the box, every minute as `build` (igneum-ci-red.timer):
|
||||
// every recorded run not yet posted goes as one line to the
|
||||
// hidden updates channel (DISCORD_WEBHOOK_UPDATES in the
|
||||
// credentials file), then is marked posted in the state file;
|
||||
// without --live the line is printed, not sent
|
||||
// node tools/ci/red-watch.mjs digest --file <red.jsonl> [--live] [--now]
|
||||
// the daily line: at the first pass at or after 09:00 London
|
||||
// (or at once with --now), one line to the updates channel
|
||||
// counting the last 24 h of red rows, CI and box, per class,
|
||||
// with the guard each class has; once per London day
|
||||
// node tools/ci/red-watch.mjs tick --file <red.jsonl> [--live] post, then digest (what igneum-ci-red.timer runs every minute)
|
||||
// node tools/ci/red-watch.mjs --self-test record twice = one line; post = one send; post again = none;
|
||||
// a box row is counted, never posted alone; the digest once a day
|
||||
//
|
||||
// Box rows: infra/build-server/remote-run.sh appends a line with "source":"box" for every red build, suite or check on
|
||||
// igneum-build-1 (class instant, compile-error, test-failure, no-test-matched, preflight-*, slot-timeout, no-dir, other).
|
||||
// Those are not posted one by one (an agent iterating red to green would flood the channel); the digest counts them.
|
||||
//
|
||||
// Files: the record file is written by the runner user (one object per line: run_id, workflow, branch, sha, title, failed,
|
||||
// url, at); the poster's state (which run ids were posted, when) is $IGNEUM_CI_RED_STATE, default
|
||||
// ~/.config/igneum/ci-red-posted.json, so the two users never write the same file. Credentials: $IGNEUM_DISCORD_ENV
|
||||
// (default ~/.config/igneum/discord), KEY=VALUE lines, mode 600, never printed: a webhook URL never appears in any output,
|
||||
// only the key's name. The orchestrator reads the record file (ssh build@<box> cat /srv/ci-red/red.jsonl) or the channel.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const has = (name) => args.includes(name);
|
||||
const CRED_FILE = process.env.IGNEUM_DISCORD_ENV || path.join(os.homedir(), '.config', 'igneum', 'discord');
|
||||
const STATE_FILE = process.env.IGNEUM_CI_RED_STATE || path.join(os.homedir(), '.config', 'igneum', 'ci-red-posted.json');
|
||||
const WEBHOOK_KEY = 'DISCORD_WEBHOOK_UPDATES';
|
||||
// what stops each class now (named in the digest so the line teaches, not just counts); docs/analysis/ci-failures-2026-10-06.md
|
||||
export const GUARDS = {
|
||||
'ci': 'the pre-push gate (tools/ci/pre-push.sh, the same checks CI runs, before any push to master or release-*)',
|
||||
'instant': 'pre-flight in remote-run.sh (manifest, -p package, feature, subcommand checked in a second) and the kept run log',
|
||||
'preflight-manifest': 'refused before the slot: the manifest did not parse',
|
||||
'preflight-package': 'refused before the slot: the -p package does not exist',
|
||||
'preflight-feature': 'refused before the slot: the feature does not exist on that package',
|
||||
'preflight-subcommand': 'refused before the slot: cargo has no such subcommand on the box (provision.sh installs it)',
|
||||
'no-test-matched': 'refused after the run: the test filter matched nothing (exit 3 instead of a green "0 tests")',
|
||||
'compile-error': 'iteration: the box is the pre-check (a Mac check takes 12 to 18 min); the run log is kept on the box',
|
||||
'link-error': 'iteration; the run log is kept on the box',
|
||||
'test-failure': 'iteration; the run log is kept on the box',
|
||||
'slot-timeout': 'two slots since 20:3x UK on 6 October; the queue is visible on the workers page',
|
||||
'no-dir': 'one run per worktree at a time (the per-worktree lock in remote-run.sh)',
|
||||
'other': 'read the kept run log (/srv/builds/_log/runs/<id>.log)',
|
||||
};
|
||||
|
||||
export function readLines(file) {
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
||||
}
|
||||
|
||||
export function runFromEnv(env = process.env) {
|
||||
const need = ['GITHUB_RUN_ID', 'GITHUB_REPOSITORY', 'GITHUB_REF_NAME', 'GITHUB_SHA', 'GITHUB_WORKFLOW'];
|
||||
for (const k of need) if (!env[k]) throw new Error(`record: ${k} is not set (this command runs inside a GitHub Actions job)`);
|
||||
const server = env.GITHUB_SERVER_URL || 'https://github.com';
|
||||
return {
|
||||
run_id: String(env.GITHUB_RUN_ID), attempt: Number(env.GITHUB_RUN_ATTEMPT || 1), workflow: env.GITHUB_WORKFLOW,
|
||||
branch: env.GITHUB_REF_NAME, sha: env.GITHUB_SHA.slice(0, 7), event: env.GITHUB_EVENT_NAME || '',
|
||||
url: `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// The failed jobs and their first failed step, from the run's jobs API with the job's own token. The `red` job itself
|
||||
// (the caller) is skipped by name. Any API trouble gives an empty list and a note, never a thrown error: the line is
|
||||
// the thing that must land.
|
||||
export async function failedJobs(env = process.env, fetchImpl = fetch) {
|
||||
const token = env.GITHUB_TOKEN; const repo = env.GITHUB_REPOSITORY; const id = env.GITHUB_RUN_ID;
|
||||
const api = env.GITHUB_API_URL || 'https://api.github.com';
|
||||
if (!token) return { failed: [], note: 'no GITHUB_TOKEN; failed steps not read' };
|
||||
try {
|
||||
const r = await fetchImpl(`${api}/repos/${repo}/actions/runs/${id}/jobs?per_page=100`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'User-Agent': 'igneum-red-watch' },
|
||||
});
|
||||
if (!r.ok) return { failed: [], note: `jobs API ${r.status}` };
|
||||
const j = await r.json();
|
||||
const failed = [];
|
||||
for (const job of j.jobs || []) {
|
||||
if (job.name === (env.GITHUB_JOB_NAME || 'red watcher') || /^red watcher/.test(job.name)) continue;
|
||||
if (job.conclusion === 'success' || job.conclusion === 'skipped' || job.conclusion === null) continue;
|
||||
const step = (job.steps || []).find((s) => s.conclusion && s.conclusion !== 'success' && s.conclusion !== 'skipped');
|
||||
const zeroSteps = !(job.steps || []).length;
|
||||
failed.push({ job: job.name, conclusion: job.conclusion, step: step ? step.name : (zeroSteps ? '(job never started: runner or billing)' : '(no step)') });
|
||||
}
|
||||
return { failed, note: '' };
|
||||
} catch (e) {
|
||||
return { failed: [], note: `jobs API: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function record(file, env = process.env, fetchImpl = fetch, title = '') {
|
||||
const run = runFromEnv(env);
|
||||
const existing = readLines(file);
|
||||
if (existing.some((l) => l.run_id === run.run_id && l.attempt === run.attempt)) {
|
||||
return { written: false, run }; // one line per run attempt, however many times the job is re-run or retried
|
||||
}
|
||||
const { failed, note } = await failedJobs(env, fetchImpl);
|
||||
const line = { ...run, title: (title || env.RED_WATCH_TITLE || '').slice(0, 100), failed, note };
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.appendFileSync(file, JSON.stringify(line) + '\n');
|
||||
return { written: true, run: line };
|
||||
}
|
||||
|
||||
export function formatLine(l) {
|
||||
const where = l.failed.length ? l.failed.map((f) => `${f.job.replace(/,.*$/, '')} at "${f.step}"`).join('; ') : (l.note || 'no step detail');
|
||||
const title = l.title ? ` "${l.title}"` : '';
|
||||
return `CI red: ${l.workflow} on ${l.branch} @${l.sha}${title}: ${where} ${l.url}`;
|
||||
}
|
||||
|
||||
function readCredentials(file) {
|
||||
if (!fs.existsSync(file)) return {};
|
||||
const out = {};
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
|
||||
const line = raw.trim(); if (!line || line.startsWith('#')) continue;
|
||||
const i = line.indexOf('='); if (i < 0) continue;
|
||||
out[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readState(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { posted: {} }; } }
|
||||
function writeState(file, state) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(state, null, 1) + '\n', { mode: 0o600 }); }
|
||||
|
||||
export async function post(file, { live = false, stateFile = STATE_FILE, credFile = CRED_FILE, fetchImpl = fetch, log = console.log } = {}) {
|
||||
const lines = readLines(file);
|
||||
const state = readState(stateFile);
|
||||
const pending = lines.filter((l) => l.source !== 'box' && !state.posted[`${l.run_id}.${l.attempt || 1}`]);
|
||||
const boxRows = lines.filter((l) => l.source === 'box').length;
|
||||
if (!pending.length) { log(`ci-red: nothing to post (${lines.length} recorded, ${boxRows} box rows for the digest, the rest posted)`); return { sent: 0, pending: 0 }; }
|
||||
const creds = readCredentials(credFile);
|
||||
const hook = creds[WEBHOOK_KEY];
|
||||
let sent = 0;
|
||||
for (const l of pending) {
|
||||
const text = formatLine(l);
|
||||
if (!live) { log(`ci-red (dry run, not sent): ${text}`); continue; }
|
||||
if (!hook) { log(`ci-red: ${WEBHOOK_KEY} is not in the credentials file; ${pending.length} line(s) wait (the line itself is in ${file})`); return { sent: 0, pending: pending.length, missingKey: true }; }
|
||||
try {
|
||||
const r = await fetchImpl(hook, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'igneum-red-watch' },
|
||||
body: JSON.stringify({ username: 'Igneum CI', content: text.slice(0, 1900), allowed_mentions: { parse: [] } }) });
|
||||
if (!r.ok && r.status !== 204) { log(`ci-red: the webhook answered ${r.status} for run ${l.run_id}; retried next tick`); continue; }
|
||||
state.posted[`${l.run_id}.${l.attempt || 1}`] = new Date().toISOString(); sent += 1;
|
||||
log(`ci-red: posted run ${l.run_id} (${l.workflow} on ${l.branch} @${l.sha})`);
|
||||
} catch (e) {
|
||||
log(`ci-red: send failed for run ${l.run_id}: ${e.message.replace(/https?:\/\/\S+/g, '<url>')}; retried next tick`);
|
||||
}
|
||||
}
|
||||
if (live) writeState(stateFile, state);
|
||||
return { sent, pending: pending.length - sent };
|
||||
}
|
||||
|
||||
// The London day of a Date (YYYY-MM-DD) and its hour, without a time zone library
|
||||
function london(d) {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }).formatToParts(d);
|
||||
const get = (t) => parts.find((p) => p.type === t).value;
|
||||
return { day: `${get('year')}-${get('month')}-${get('day')}`, hour: Number(get('hour')) % 24 };
|
||||
}
|
||||
|
||||
export function digestText(lines, now = new Date()) {
|
||||
const since = now.getTime() - 24 * 3600 * 1000;
|
||||
const recent = lines.filter((l) => Date.parse(l.at) >= since);
|
||||
const ci = recent.filter((l) => l.source !== 'box'); const box = recent.filter((l) => l.source === 'box');
|
||||
const byClass = {};
|
||||
for (const l of box) byClass[l.class || 'other'] = (byClass[l.class || 'other'] || 0) + 1;
|
||||
const ciSteps = {};
|
||||
for (const l of ci) for (const f of (l.failed || [])) ciSteps[f.step] = (ciSteps[f.step] || 0) + 1;
|
||||
const parts = [`CI red digest, last 24 h: ${recent.length} red run(s): ${ci.length} CI, ${box.length} on the box.`];
|
||||
if (ci.length) parts.push('CI: ' + Object.entries(ciSteps).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${v} x "${k}"`).join(', ') + ` (guard: ${GUARDS.ci}).`);
|
||||
if (box.length) parts.push('Box, by class: ' + Object.entries(byClass).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${v} ${k} (${GUARDS[k] || GUARDS.other})`).join('; ') + '.');
|
||||
if (!recent.length) parts.push('Nothing was red.');
|
||||
return parts.join(' ').slice(0, 1900);
|
||||
}
|
||||
|
||||
export async function digest(file, { live = false, now = new Date(), force = false, stateFile = STATE_FILE, credFile = CRED_FILE, fetchImpl = fetch, log = console.log } = {}) {
|
||||
const state = readState(stateFile);
|
||||
const { day, hour } = london(now);
|
||||
if (!force) {
|
||||
if (hour < 9) return { sent: false, why: 'before 09:00 London' };
|
||||
if (state.digest_day === day) return { sent: false, why: 'already sent today' };
|
||||
}
|
||||
const text = digestText(readLines(file), now);
|
||||
if (!live) { log(`ci-red digest (dry run, not sent): ${text}`); return { sent: false, why: 'dry run', text }; }
|
||||
const hook = readCredentials(credFile)[WEBHOOK_KEY];
|
||||
if (!hook) { log(`ci-red digest: ${WEBHOOK_KEY} is not in the credentials file; the digest waits`); return { sent: false, why: 'no key', text }; }
|
||||
try {
|
||||
const r = await fetchImpl(hook, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': 'igneum-red-watch' },
|
||||
body: JSON.stringify({ username: 'Igneum CI', content: text, allowed_mentions: { parse: [] } }) });
|
||||
if (!r.ok && r.status !== 204) { log(`ci-red digest: the webhook answered ${r.status}; retried next pass`); return { sent: false, why: `webhook ${r.status}`, text }; }
|
||||
} catch (e) {
|
||||
log(`ci-red digest: send failed: ${e.message.replace(/https?:\/\/\S+/g, '<url>')}; retried next pass`); return { sent: false, why: 'send failed', text };
|
||||
}
|
||||
state.digest_day = day; writeState(stateFile, state);
|
||||
log(`ci-red digest: posted for ${day}`);
|
||||
return { sent: true, text };
|
||||
}
|
||||
|
||||
async function selfTest() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'red-watch-'));
|
||||
const file = path.join(dir, 'red.jsonl'); const stateFile = path.join(dir, 'posted.json'); const credFile = path.join(dir, 'discord');
|
||||
const env = { GITHUB_RUN_ID: '424242', GITHUB_RUN_ATTEMPT: '1', GITHUB_REPOSITORY: 'igneum-network/igneum', GITHUB_REF_NAME: 'master',
|
||||
GITHUB_SHA: '0f0abc6deadbeef', GITHUB_WORKFLOW: 'ci', GITHUB_TOKEN: 'x', RED_WATCH_TITLE: 'Merge box-work 2bd3bec: the repro script re-stamps its clones' };
|
||||
const jobs = { jobs: [
|
||||
{ name: 'site build, link check, identity grep', conclusion: 'failure', steps: [{ name: 'site build', conclusion: 'success' }, { name: 'identity grep of the public export list', conclusion: 'failure' }] },
|
||||
{ name: 'igneum-pow tests, igneum-census build', conclusion: 'success', steps: [] },
|
||||
{ name: 'simulators, quick modes', conclusion: 'failure', steps: [] },
|
||||
{ name: 'red watcher (master and release-* only)', conclusion: null, steps: [] },
|
||||
] };
|
||||
const fakeFetch = async () => ({ ok: true, status: 200, json: async () => jobs });
|
||||
const fails = [];
|
||||
const a = await record(file, env, fakeFetch); const b = await record(file, env, fakeFetch);
|
||||
if (!a.written || b.written) fails.push('record: the second call for the same run wrote a second line');
|
||||
const lines = readLines(file);
|
||||
if (lines.length !== 1) fails.push(`record: ${lines.length} lines, expected 1`);
|
||||
if (lines[0].failed.length !== 2) fails.push(`record: ${lines[0].failed.length} failed jobs, expected 2 (the watcher itself and the green job skipped)`);
|
||||
if (lines[0].failed[0].step !== 'identity grep of the public export list') fails.push('record: the failed step was not the first non-success step');
|
||||
if (lines[0].failed[1].step !== '(job never started: runner or billing)') fails.push('record: a job with no steps was not named as never started');
|
||||
const text = formatLine(lines[0]);
|
||||
if (!/^CI red: ci on master @0f0abc6 "Merge box-work/.test(text) || !text.includes('actions/runs/424242')) fails.push(`format: ${text}`);
|
||||
// post, dry run: prints, sends nothing, marks nothing
|
||||
let printed = []; const log = (s) => printed.push(s);
|
||||
const sends = []; const hookFetch = async (url, init) => { sends.push({ url, body: JSON.parse(init.body) }); return { ok: true, status: 204 }; };
|
||||
await post(file, { live: false, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (sends.length !== 0 || !printed.some((s) => s.includes('dry run'))) fails.push('post: the dry run sent or did not print');
|
||||
// post, live, no key: says which key is missing, names no URL, sends nothing
|
||||
fs.writeFileSync(credFile, 'DISCORD_WEBHOOK_NUMBERS=https://discord.example/api/webhooks/1/secret\n', { mode: 0o600 });
|
||||
printed = [];
|
||||
const r0 = await post(file, { live: true, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (!r0.missingKey || sends.length !== 0 || !printed.some((s) => s.includes(WEBHOOK_KEY))) fails.push('post: a missing updates key was not reported by name');
|
||||
if (printed.some((s) => s.includes('secret'))) fails.push('post: a webhook URL leaked into the log');
|
||||
// post, live, with the key: one send with the line, then marked posted; a second pass sends nothing
|
||||
fs.writeFileSync(credFile, `${WEBHOOK_KEY}=https://discord.example/api/webhooks/2/secret2\n`, { mode: 0o600 });
|
||||
printed = [];
|
||||
const r1 = await post(file, { live: true, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (r1.sent !== 1 || sends.length !== 1 || sends[0].body.content !== text) fails.push(`post: expected one send of the line, got ${sends.length}`);
|
||||
if (sends[0].body.allowed_mentions?.parse?.length !== 0) fails.push('post: mentions are not disabled');
|
||||
if (printed.some((s) => s.includes('secret2'))) fails.push('post: the webhook URL leaked into the log');
|
||||
const r2 = await post(file, { live: true, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (r2.sent !== 0 || sends.length !== 1) fails.push('post: the second pass sent the same run again');
|
||||
// a failing webhook leaves the run pending for the next tick
|
||||
const env2 = { ...env, GITHUB_RUN_ID: '424243' };
|
||||
await record(file, env2, fakeFetch);
|
||||
const badFetch = async () => ({ ok: false, status: 500 });
|
||||
const r3 = await post(file, { live: true, stateFile, credFile, fetchImpl: badFetch, log });
|
||||
if (r3.sent !== 0 || r3.pending !== 1) fails.push('post: a 500 from the webhook did not keep the run pending');
|
||||
// a box row (remote-run.sh's shape) is never posted alone, and the digest counts it per class with its guard
|
||||
const boxLine = { source: 'box', run_id: 'igneum-build-1-1-1', attempt: 1, workflow: 'box:suite', branch: 'x', sha: 'abc1234', url: '', at: new Date().toISOString(),
|
||||
title: 'cargo test --release -p kaspa-consensus --lib finality', failed: [{ job: 'wt/crate', conclusion: 'failure', step: 'instant: exit 101 after 0 s' }], class: 'instant', note: '' };
|
||||
fs.appendFileSync(file, JSON.stringify(boxLine) + '\n');
|
||||
const before = sends.length;
|
||||
const r4 = await post(file, { live: true, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
// the pending CI run 424243 goes now (one send), the box row does not
|
||||
if (sends.length !== before + 1 || r4.pending !== 0 || !sends[sends.length - 1].body.content.includes('actions/runs/424243')) fails.push('post: a box row was posted alone or the pending CI run was not');
|
||||
const text2 = digestText(readLines(file), new Date());
|
||||
if (!/3 red run\(s\): 2 CI, 1 on the box/.test(text2) || !text2.includes('1 instant (pre-flight')) fails.push(`digest text: ${text2}`);
|
||||
const at0830 = new Date('2026-10-07T07:30:00Z'); // 08:30 London (BST)
|
||||
const d0 = await digest(file, { live: true, now: at0830, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (d0.sent || d0.why !== 'before 09:00 London') fails.push(`digest: sent before 09:00 London (${d0.why})`);
|
||||
const at0905 = new Date('2026-10-07T08:05:00Z'); // 09:05 London
|
||||
const d1 = await digest(file, { live: true, now: at0905, stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (!d1.sent || sends[sends.length - 1].body.content !== d1.text) fails.push('digest: not sent at 09:05 London or the text differs');
|
||||
const d2 = await digest(file, { live: true, now: new Date('2026-10-07T15:00:00Z'), stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (d2.sent || d2.why !== 'already sent today') fails.push('digest: sent twice in one London day');
|
||||
const d3 = await digest(file, { live: true, now: new Date('2026-10-08T08:05:00Z'), stateFile, credFile, fetchImpl: hookFetch, log });
|
||||
if (!d3.sent) fails.push('digest: not sent the next day');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (fails.length) { for (const f of fails) console.error(`self-test failed: ${f}`); process.exit(1); }
|
||||
console.log('self-test passed: one line per run however often record runs; the dry run sends nothing; a missing key is named, never a URL; one live send per run; a webhook error keeps the run pending; a box row is counted, never posted alone; the digest goes once per London day, at or after 09:00');
|
||||
}
|
||||
|
||||
const cmd = args[0];
|
||||
if (cmd === '--self-test') {
|
||||
await selfTest();
|
||||
} else if (cmd === 'record') {
|
||||
const file = flag('--file'); if (!file) { console.error('record: --file <red.jsonl> is required'); process.exit(2); }
|
||||
const r = await record(file, process.env, fetch, flag('--title') || '');
|
||||
console.log(r.written ? `ci-red: recorded ${formatLine(r.run)}` : `ci-red: run ${r.run.run_id} attempt ${r.run.attempt} already recorded`);
|
||||
} else if (cmd === 'post') {
|
||||
const file = flag('--file'); if (!file) { console.error('post: --file <red.jsonl> is required'); process.exit(2); }
|
||||
await post(file, { live: has('--live') });
|
||||
} else if (cmd === 'digest') {
|
||||
const file = flag('--file'); if (!file) { console.error('digest: --file <red.jsonl> is required'); process.exit(2); }
|
||||
const r = await digest(file, { live: has('--live'), force: has('--now') });
|
||||
if (!r.sent && r.why !== 'dry run') console.log(`ci-red digest: not sent (${r.why})`);
|
||||
} else if (cmd === 'tick') {
|
||||
const file = flag('--file'); if (!file) { console.error('tick: --file <red.jsonl> is required'); process.exit(2); }
|
||||
await post(file, { live: has('--live') });
|
||||
const r = await digest(file, { live: has('--live') });
|
||||
if (!r.sent && r.why !== 'dry run' && r.why !== 'before 09:00 London' && r.why !== 'already sent today') console.log(`ci-red digest: not sent (${r.why})`);
|
||||
} else {
|
||||
console.error('usage: red-watch.mjs record --file <red.jsonl> | post --file <red.jsonl> [--live] | digest --file <red.jsonl> [--live] [--now] | tick --file <red.jsonl> [--live] | --self-test'); process.exit(2);
|
||||
}
|
||||
|
|
@ -1,10 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
# Every tracked path must be one Windows can hold: no : * ? " < > | in a name, no name ending in a dot or a space.
|
||||
# 6 October 2026: docs/plans/site-ui-3-shots/after/address_igneumdev:qz9h....jpg (a colon from an address in a screenshot
|
||||
# name) made actions/checkout fail on windows-latest ("invalid path", git exit 128), and every Windows build of the tree
|
||||
# died at the checkout. Runs in CI (ubuntu, before any Windows job) and locally. Exit 1 with the paths listed.
|
||||
# Every tracked path must be one Windows can hold. 6 October 2026: a screenshot named after an address carried a colon
|
||||
# (docs/plans/site-ui-3-shots/after/address_igneumdev:qz9h....jpg), actions/checkout on windows-latest failed with
|
||||
# "invalid path" (git exit 128), and every Windows build of the tree died at the checkout for forty minutes.
|
||||
#
|
||||
# Rules (NTFS and the Win32 namespace):
|
||||
# no : * ? " < > | in a name, and no control character;
|
||||
# no name ending in a dot or a space;
|
||||
# no reserved device name as a name or as the stem of one (CON, PRN, AUX, NUL, COM1-9, LPT1-9, any case);
|
||||
# no path longer than 240 characters (MAX_PATH is 260 and a checkout prefix takes the rest).
|
||||
#
|
||||
# tools/ci/windows-paths-check.sh every tracked path (CI, the pre-push gate)
|
||||
# tools/ci/windows-paths-check.sh --staged the paths being committed (the pre-commit hook)
|
||||
# tools/ci/windows-paths-check.sh --self-test the rules fire on each bad shape and pass a good one
|
||||
# Exit 1 with the offending paths listed.
|
||||
set -euo pipefail
|
||||
|
||||
check_paths() {
|
||||
# stdin: one path per line. Prints one line per offence. Returns 1 if any. One awk pass (a subprocess per path took
|
||||
# 23 s over 2,500 files on the Mac; this takes well under a second).
|
||||
awk '
|
||||
function reserved(name, stem) { stem = name; sub(/\..*$/, "", stem); return toupper(stem) ~ /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/ }
|
||||
{
|
||||
p = $0; if (p == "") next
|
||||
if (p ~ /[:*?"<>|]/ || p ~ /[\001-\037\177]/) { print " " p " (a character Windows forbids: one of : * ? \" < > | or a control character)"; bad = 1; next }
|
||||
if (p ~ /[. ]$/ || p ~ /(^|\/)[^\/]*[. ]\//) { print " " p " (a name ending in a dot or a space)"; bad = 1; next }
|
||||
if (length(p) > 240) { print " " p " (" length(p) " characters; over 240)"; bad = 1; next }
|
||||
n = split(p, parts, "/")
|
||||
for (i = 1; i <= n; i++) if (reserved(parts[i])) { print " " p " (reserved device name " parts[i] ")"; bad = 1; break }
|
||||
}
|
||||
END { exit bad ? 1 : 0 }'
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
fails=0
|
||||
for bad in 'docs/shots/address_igneumdev:qz9h.jpg' 'a/b/what?.md' 'a/trailing./x' 'a/trailing ' 'docs/nul.txt' 'x/COM1' 'x/LpT3.log' "$(printf 'd/%0.s' $(seq 1 125))f.txt"; do
|
||||
if printf '%s\n' "$bad" | check_paths >/dev/null; then echo "self-test failed: accepted '$bad'"; fails=1; fi
|
||||
done
|
||||
for good in 'docs/plans/site-ui-3-shots/after/address_igneumdev-qz9h.jpg' 'tools/ci/windows-paths-check.sh' 'a/console.log' 'a/null.rs' 'a/com10.txt' 'a/.gitignore' 'a/b.c.d'; do
|
||||
if ! printf '%s\n' "$good" | check_paths >/dev/null; then echo "self-test failed: rejected '$good'"; fails=1; fi
|
||||
done
|
||||
[ "$fails" = 0 ] && echo "self-test passed: colon, question mark, trailing dot, trailing space, NUL, COM1, LpT3 and a 250-character path fail; seven ordinary paths pass"
|
||||
exit $fails
|
||||
fi
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
bad="$(git ls-files -z | tr '\0' '\n' | grep -E '[:*?"<>|]|[. ]$|(^|/)[^/]*[. ]/' || true)"
|
||||
if [ -n "$bad" ]; then echo "windows-paths: these tracked paths cannot exist on Windows (rename them):"; printf '%s\n' "$bad" | sed 's/^/ /'; exit 1; fi
|
||||
echo "windows-paths: every tracked path is valid on Windows ($(git ls-files | wc -l | tr -d ' ') files)"
|
||||
if [ "${1:-}" = "--staged" ]; then
|
||||
list="$(git diff --cached --name-only --diff-filter=ACR -z | tr '\0' '\n')"; what="staged paths"
|
||||
else
|
||||
list="$(git ls-files -z | tr '\0' '\n')"; what="tracked paths"
|
||||
fi
|
||||
if out="$(printf '%s\n' "$list" | check_paths)"; then
|
||||
echo "windows-paths: every one of $(printf '%s\n' "$list" | grep -c . || true) $what is valid on Windows"
|
||||
else
|
||||
echo "windows-paths: these $what cannot exist on Windows (rename them before committing):"; printf '%s\n' "$out"; exit 1
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ export function collect() {
|
|||
const recent = parseBuildsJsonl(logText, { limit: 200 });
|
||||
const mac = source('mac', now), pcs = source('pcs', now);
|
||||
let headline = null; try { headline = JSON.parse(readFileSync(join(OUT_DIR, 'headline.json'), 'utf8')); } catch { headline = null; }
|
||||
// the background capacity layer (infra/build-server/capacity) writes capacity.json beside this file; show it when
|
||||
// present and under 10 min old (the layer's controller stamps it every slice and on every pause/resume)
|
||||
let background = null;
|
||||
try {
|
||||
const p = join(OUT_DIR, 'capacity.json'); const st = statSync(p); const age = Math.round((now - st.mtimeMs) / 1000);
|
||||
const j = JSON.parse(readFileSync(p, 'utf8'));
|
||||
background = { ...j, meta: { at: iso(st.mtimeMs), age_s: age, stale: age > 600, ok: true } };
|
||||
} catch (e) { background = { meta: { ok: false, error: e.code === 'ENOENT' ? 'the capacity layer has not written yet' : String(e.message || e) } }; }
|
||||
const doc = {
|
||||
v: 1,
|
||||
generated_at: iso(now),
|
||||
|
|
@ -143,7 +151,8 @@ export function collect() {
|
|||
},
|
||||
mac: mac.data, pcs: pcs.data,
|
||||
headline, baselines: BASELINES,
|
||||
sources: { box: { ok: true, at: iso(now) }, mac: mac.meta, pcs: pcs.meta },
|
||||
background,
|
||||
sources: { box: { ok: true, at: iso(now) }, mac: mac.meta, pcs: pcs.meta, background: background && background.meta },
|
||||
};
|
||||
return doc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@
|
|||
<h2>Queue <span id="queue-sub"></span></h2>
|
||||
<div class="lane" id="queue"></div>
|
||||
|
||||
<h2>Background <span id="bg-sub">idle cores, yields to builds</span></h2>
|
||||
<div class="lane" id="background"></div>
|
||||
|
||||
<h2>Recently done <span>click a row for the closing lines</span></h2>
|
||||
<div class="tl" id="done"></div>
|
||||
|
||||
|
|
@ -261,6 +264,32 @@ function renderNow() {
|
|||
}
|
||||
function tick() { for (const el of document.querySelectorAll('.job.running[data-start]')) { const s = el.dataset.start; if (!s) continue; const e = el.querySelector('.elapsed'); if (e) e.textContent = fmtDurShort((Date.now() - Date.parse(s)) / 1000); } }
|
||||
|
||||
const BG_PRIORITY = { 'pow-fuzz': 1, 'sync-fuzz': 2, 'sim-sweeps': 3, 'model-sweeps': 4, 'clippy-audit': 5 };
|
||||
const BG_LABEL = { 'pow-fuzz': 'igneum-pow fuzz', 'sync-fuzz': 'sync-request fuzz', 'sim-sweeps': 'GHOSTDAG + finality sweeps', 'model-sweeps': 'N-ladder + chip model', 'clippy-audit': 'clippy + audit per branch' };
|
||||
function renderBackground() {
|
||||
const bg = D.background;
|
||||
const el = $('background'); const sub = $('bg-sub');
|
||||
if (!bg || (bg.meta && bg.meta.ok === false)) {
|
||||
sub.textContent = 'idle cores, yields to builds';
|
||||
el.innerHTML = `<div class="empty">${esc((bg && bg.meta && bg.meta.error) || 'The capacity layer (infra/build-server/capacity) has not written capacity.json yet. It starts with igneum-capacity.service and writes within one slice.')}</div>`;
|
||||
return;
|
||||
}
|
||||
const stale = bg.meta && bg.meta.stale;
|
||||
const ctl = bg.state || 'unknown';
|
||||
sub.innerHTML = `${esc(bg.host || 'igneum-build-1')} · controller <b class="${ctl === 'paused' || ctl === 'waiting' ? 'warn' : ''}">${esc(ctl)}</b>${bg.paused_reason ? ' · ' + esc(bg.paused_reason) : ''}${bg.current_job ? ' · on ' + esc(bg.current_job) : ''}${stale ? ' · <b class="no">stale</b>' : ''}`;
|
||||
const jobs = bg.jobs || {};
|
||||
const names = Object.keys(jobs).sort((a, b) => (jobs[a].priority || BG_PRIORITY[a] || 9) - (jobs[b].priority || BG_PRIORITY[b] || 9));
|
||||
if (!names.length) { el.innerHTML = `<div class="empty">No jobs reported yet.</div>`; return; }
|
||||
el.innerHTML = names.map(n => {
|
||||
const j = jobs[n]; const pr = j.priority || BG_PRIORITY[n] || '';
|
||||
const running = j.state === 'running' || j.state === 'ran';
|
||||
const panics = j.counters && j.counters.panics;
|
||||
const kindCls = panics ? 'error' : running ? 'running' : '';
|
||||
const c = j.counters || {};
|
||||
const nums = Object.entries(c).filter(([k]) => k !== 'panics').map(([k, v]) => `${esc(k)} ${esc(String(v))}`).join(' · ');
|
||||
return `<div class="job"><div class="top"><div><div class="title">${esc(BG_LABEL[n] || n)}</div><div class="where">priority ${esc(String(pr))} · ${esc(n)}${n === D.background.current_job ? ' · now' : ''}</div></div>${pill(j.state || 'idle', kindCls)}</div><div class="cmd" title="${esc(j.summary || '')}">${esc(j.summary || '')}</div>${nums ? `<div class="cmd" style="opacity:.8">${nums}</div>` : ''}<div class="foot"><span>${panics ? `<b class="no">${esc(String(panics))} saved</b>` : '0 panics'}${j.out ? ' · ' + esc(String(j.out).replace(/^.*\/out\//, 'out/')) : ''}</span><span>${esc(ago(j.updated_at))}</span></div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
function renderQueue() {
|
||||
const items = [];
|
||||
for (const w of (D.box && D.box.queue) || []) items.push({ where: D.box.name, title: w.label ? kindLabel(w.kind) : 'a build', sub: w.label || 'label unknown: the waiter writes none until its slot is taken', since: w.since, wait: w.waiting_s });
|
||||
|
|
@ -345,7 +374,7 @@ function apply(d) {
|
|||
const live = $('live'); live.className = 'live' + (age > 900 ? ' down' : age > 420 ? ' stale' : '');
|
||||
const u = ukTime(d.generated_at);
|
||||
$('stamp').innerHTML = `${age > 420 ? 'stale: ' : ''}written ${t(d.generated_at)} (${esc(ago(d.generated_at))})${d._feed ? ' · ' + esc(d._feed) : ''}`;
|
||||
renderServer(d.box, d.sources && d.sources.box); renderCrew(); renderNow(); renderQueue(); renderDone(); renderHeadline(); renderAnalytics(); renderSources();
|
||||
renderServer(d.box, d.sources && d.sources.box); renderCrew(); renderNow(); renderQueue(); renderBackground(); renderDone(); renderHeadline(); renderAnalytics(); renderSources();
|
||||
}
|
||||
const LIVE_URL = 'https://build.igneum.network/workers.json'; // the box itself, 30 s fresh (Caddy, read-only); the folder copy is up to 5 min behind
|
||||
let skipLive = 0, feed = '';
|
||||
|
|
|
|||