Compare commits
450 commits
master
...
pool-finis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9063631f0 | ||
|
|
987f3c3d5c | ||
|
|
a708250785 | ||
|
|
1d06bccad0 | ||
|
|
92382e9bab | ||
|
|
6ae30017d6 | ||
|
|
777525478b | ||
|
|
1ece1202e0 | ||
|
|
32c57b4f47 | ||
|
|
e94c70a7eb | ||
|
|
8fb3979077 | ||
|
|
a65f6c6a9f | ||
|
|
58a59beef9 | ||
|
|
930127503a | ||
|
|
b4070cf45d | ||
|
|
0e7b3a908c | ||
|
|
ae1e106ad8 | ||
|
|
ca303490cf | ||
|
|
4cbc1b7211 | ||
|
|
39a655fb33 | ||
|
|
fe203fc436 | ||
|
|
7c1482b537 | ||
|
|
288a0bae43 | ||
|
|
a2e27d8fbd | ||
|
|
383c9bd714 | ||
|
|
c905cc2c28 | ||
|
|
1dcaeb48b4 | ||
|
|
596706e0c3 | ||
|
|
cf1ad2458a | ||
|
|
d96e4730f9 | ||
|
|
83a58d2bb3 | ||
|
|
cd63e9b162 | ||
|
|
8ed7dc9331 | ||
|
|
0eff259dc6 | ||
|
|
7f97da78fc | ||
|
|
771c61e6fc | ||
|
|
d4a389bbb7 | ||
|
|
a26f9c0973 | ||
|
|
6e4b73c26d | ||
|
|
192e6525fd | ||
|
|
72a5ed6a3d | ||
|
|
52c9be735c | ||
|
|
5465a1c812 | ||
|
|
6286e1056e | ||
|
|
3672921da3 | ||
|
|
f7aace84c9 | ||
|
|
8a46aaf8bf | ||
|
|
6aefe1c76d | ||
|
|
712ff555ce | ||
|
|
8b732a87f3 | ||
|
|
10a229a09a | ||
|
|
bebec09c4e | ||
|
|
f0caaccb31 | ||
|
|
56309af374 | ||
|
|
09d8226256 | ||
|
|
da627013f5 | ||
|
|
bfe492c27a | ||
|
|
1e8a9f6b97 | ||
|
|
440d0e2591 | ||
|
|
2fed01a9a5 | ||
|
|
854528aea7 | ||
|
|
58838b00ce | ||
|
|
041bed0ee1 | ||
|
|
347279f447 | ||
|
|
74630524f5 | ||
|
|
a54160406d | ||
|
|
28fe496591 | ||
|
|
560729acdc | ||
|
|
1649cb61e7 | ||
|
|
166214fbdb | ||
|
|
e6eca85ca9 | ||
|
|
83c8320a6f | ||
|
|
ab7f0db141 | ||
|
|
b7734b733e | ||
|
|
8fed74529a | ||
|
|
7c056b9e9b | ||
|
|
8849f93695 | ||
|
|
59bbaf98a1 | ||
|
|
24ef132987 | ||
|
|
d09eb1ea4b | ||
|
|
fa7615b5e4 | ||
|
|
03b5e10122 | ||
|
|
b2ff6aea9a | ||
|
|
01ce2c637a | ||
|
|
4890dffdc7 | ||
|
|
466be2d9ae | ||
|
|
3823e4812b | ||
|
|
cac8578543 | ||
|
|
f61fb8d706 | ||
|
|
fd08709907 | ||
|
|
4f11482a5a | ||
|
|
aea3ecb31e | ||
|
|
9c0a4687f7 | ||
|
|
e93b2749c6 | ||
|
|
792862f3b6 | ||
|
|
6778331a62 | ||
|
|
3b9c531bf1 | ||
|
|
263d56e3de | ||
|
|
d9fd13bcc7 | ||
|
|
590af86d49 | ||
|
|
ec997585bb | ||
|
|
e87b7da8ac | ||
|
|
384a8f8d81 | ||
|
|
45b1a58190 | ||
|
|
8ddeea78d8 | ||
|
|
d9920497e4 | ||
|
|
2c83c28d06 | ||
|
|
2449a07a7b | ||
|
|
c5541ee964 | ||
|
|
ff5039cef2 | ||
|
|
ade213bdcf | ||
|
|
817effbd07 | ||
|
|
fb3a199e00 | ||
|
|
00ccdfbc97 | ||
|
|
eab7a213ee | ||
|
|
37cd30d77f | ||
|
|
b0466f5619 | ||
|
|
910c14d361 | ||
|
|
3ecb289261 | ||
|
|
234871eeeb | ||
|
|
2832fe03fc | ||
|
|
9322e31f64 | ||
|
|
aa1d0c854f | ||
|
|
aac8ca3731 | ||
|
|
8191349b97 | ||
|
|
3e70204944 | ||
|
|
291bd2c802 | ||
|
|
1e957d10f7 | ||
|
|
940b56718f | ||
|
|
6b18a0a54e | ||
|
|
2c7eafa108 | ||
|
|
787ac8091a | ||
|
|
da883e72d4 | ||
|
|
3a5b8093f6 | ||
|
|
a4f3f8466e | ||
|
|
e779521c3e | ||
|
|
b145ec3095 | ||
|
|
a5ac755c7d | ||
|
|
bf4dc5756e | ||
|
|
634debb4fc | ||
|
|
df711f0d43 | ||
|
|
6dfcee2e15 | ||
|
|
a4cdc6523d | ||
|
|
5fd9f1dbca | ||
|
|
bf44714747 | ||
|
|
a37d758cfb | ||
|
|
89887d9185 | ||
|
|
93545d2968 | ||
|
|
6eb5af287a | ||
|
|
0ceeed1cef | ||
|
|
0506a255da | ||
|
|
c710f2c554 | ||
|
|
468e542d79 | ||
|
|
e0bb00311b | ||
|
|
399a8a0ce0 | ||
|
|
9391d9d001 | ||
|
|
3a4f9b3899 | ||
|
|
be54089e85 | ||
|
|
bbc4528d92 | ||
|
|
a6024abf1b | ||
|
|
d175658dfc | ||
|
|
e0e0049b1a | ||
|
|
7ae9b83c7f | ||
|
|
898c137a4d | ||
|
|
ee53f26b0c | ||
|
|
e3596fa715 | ||
|
|
5654162a6b | ||
|
|
a45f09bb8f | ||
|
|
97409b51d7 | ||
|
|
ac2d20943f | ||
|
|
8824021fe5 | ||
|
|
cc7b95a4ed | ||
|
|
58a9c9f3bd | ||
|
|
e9a9ff032e | ||
|
|
ca73e8baf5 | ||
|
|
53312fe342 | ||
|
|
c1782630e2 | ||
|
|
b4b897a321 | ||
|
|
5cf709b7c2 | ||
|
|
74503f0ee3 | ||
|
|
92426f55eb | ||
|
|
c0f19ee0f3 | ||
|
|
88018638f7 | ||
|
|
21788b956c | ||
|
|
87a1f96bfb | ||
|
|
60f7391559 | ||
|
|
1be90432e1 | ||
|
|
8e36e277e5 | ||
|
|
427a3cbd87 | ||
|
|
e318dbe4d2 | ||
|
|
5cd82fdb6e | ||
|
|
aff99a9dd1 | ||
|
|
84f5f18859 | ||
|
|
ae99220d12 | ||
|
|
9b5250bbe1 | ||
|
|
04733aede7 | ||
|
|
e4e2cf1a63 | ||
|
|
c8b859b90c | ||
|
|
d1203a1ef0 | ||
|
|
9740b7f447 | ||
|
|
75c7b9925b | ||
|
|
e2a23268bd | ||
|
|
90b146dd39 | ||
|
|
2880b62f9e | ||
|
|
4682b22b9a | ||
|
|
b143f9942d | ||
|
|
a368e83144 | ||
|
|
4c71e6dcef | ||
|
|
d917b889db | ||
|
|
468f4bf2d5 | ||
|
|
abd230723a | ||
|
|
2f85719373 | ||
|
|
c9fc4463f7 | ||
|
|
a1ded4b139 | ||
|
|
2c72b4d872 | ||
|
|
bc39927a00 | ||
|
|
c928cdfcda | ||
|
|
45207d3b02 | ||
|
|
216564527a | ||
|
|
e202fb0cac | ||
|
|
587fa66cb0 | ||
|
|
338eb01945 | ||
|
|
3e15824b69 | ||
|
|
f93f893bf8 | ||
|
|
e58d79443a | ||
|
|
06f0497a8a | ||
|
|
cbcad27301 | ||
|
|
ad25e120c5 | ||
|
|
9fb6b5d769 | ||
|
|
a07d938d94 | ||
|
|
15a8e0f45f | ||
|
|
c7a6cf8889 | ||
|
|
8f2d7973ad | ||
|
|
a13b66f0dd | ||
|
|
4098bfb908 | ||
|
|
6555cc892a | ||
|
|
8b6f6ec2d4 | ||
|
|
356ee6d473 | ||
|
|
437c996b72 | ||
|
|
7b15061b27 | ||
|
|
670d0b521a | ||
|
|
a91b24a724 | ||
|
|
0af6ae09bf | ||
|
|
d9947af56a | ||
|
|
b8387b35dc | ||
|
|
64a635d205 | ||
|
|
61b6976b38 | ||
|
|
f3791ee11a | ||
|
|
619a9f3766 | ||
|
|
a06c2a08ff | ||
|
|
f5670949c3 | ||
|
|
de8ef8d5b3 | ||
|
|
fe84de7ed5 | ||
|
|
8f502a02f0 | ||
|
|
8d7d862468 | ||
|
|
63dee0fa8d | ||
|
|
2e97da46cc | ||
|
|
47dbdfbce9 | ||
|
|
8aee7d3cf0 | ||
|
|
a304359e27 | ||
|
|
dedab4ad99 | ||
|
|
c1c799fffe | ||
|
|
218e5158be | ||
|
|
17e995c93b | ||
|
|
4e00af7849 | ||
|
|
c6711bc539 | ||
|
|
ecb1b72bb9 | ||
|
|
ab5461da32 | ||
|
|
40b0435dc0 | ||
|
|
295cb240dd | ||
|
|
cdea49dd75 | ||
|
|
94534042df | ||
|
|
a76a4d9332 | ||
|
|
bc2915f7a8 | ||
|
|
1a0fdb9140 | ||
|
|
d29adeecb6 | ||
|
|
21239868b6 | ||
|
|
d9087c27bc | ||
|
|
0a37d3dd5f | ||
|
|
f454baf195 | ||
|
|
acfcd77bec | ||
|
|
1bb9bc7acb | ||
|
|
c9acb491d4 | ||
|
|
be77fa390f | ||
|
|
767fa2efb0 | ||
|
|
7f47b6ecef | ||
|
|
9d2db936cc | ||
|
|
93eafc1543 | ||
|
|
6701b79217 | ||
|
|
d5050ef769 | ||
|
|
e7f5d248f9 | ||
|
|
fdd3ac23c6 | ||
|
|
ad92c8910a | ||
|
|
1bd0cc140c | ||
|
|
5067d968f0 | ||
|
|
383e534bd7 | ||
|
|
0ca4a91385 | ||
|
|
de6a33e351 | ||
|
|
0e88f300df | ||
|
|
ff7a30f0bf | ||
|
|
2c973940c5 | ||
|
|
9d2f4ac612 | ||
|
|
d4183949d6 | ||
|
|
6fa945448b | ||
|
|
2fdcbbe814 | ||
|
|
40d51a3b33 | ||
|
|
0b939e19a3 | ||
|
|
a5d6b202ee | ||
|
|
a15c4597ae | ||
|
|
e8b57318e3 | ||
|
|
370723af31 | ||
|
|
9cc5e1080a | ||
|
|
e0a5f325c8 | ||
|
|
e4829f2dce | ||
|
|
06f9c9b1eb | ||
|
|
9f28fbe4dd | ||
|
|
a671adc473 | ||
|
|
d5651995d8 | ||
|
|
8a3d64e29c | ||
|
|
e261e5b306 | ||
|
|
8a6dbb77bc | ||
|
|
d9e575c195 | ||
|
|
fbfe71fca6 | ||
|
|
8166ea0e1f | ||
|
|
38d20dd355 | ||
|
|
b2d659d387 | ||
|
|
f90370975a | ||
|
|
8e52fa78be | ||
|
|
81926f7f0e | ||
|
|
97a23d1be2 | ||
|
|
0953ade029 | ||
|
|
c642aec535 | ||
|
|
992e6dd210 | ||
|
|
1e84f0080c | ||
|
|
d38544fa66 | ||
|
|
e62c66415a | ||
|
|
1165b0ebbe | ||
|
|
f4dd38fe7e | ||
|
|
7f82eaec23 | ||
|
|
3186848b3b | ||
|
|
9b18f4efa4 | ||
|
|
8ec702afa8 | ||
|
|
f055ffbabc | ||
|
|
7f16704235 | ||
|
|
e9649a7908 | ||
|
|
883a3a9898 | ||
|
|
eda08a549c | ||
|
|
e7cac3fe46 | ||
|
|
b623357a70 | ||
|
|
b4bd85910d | ||
|
|
c84b240c8f | ||
|
|
ba6a5c5e26 | ||
|
|
d6f4a1f7bf | ||
|
|
1c654d29ed | ||
|
|
80c50ae572 | ||
|
|
74d7e68d40 | ||
|
|
6b81a930b1 | ||
|
|
47c24adf0a | ||
|
|
a441ef090d | ||
|
|
67bc921c6c | ||
|
|
ae042bdd53 | ||
|
|
6c6ca27ea3 | ||
|
|
7a9bcd0ec9 | ||
|
|
831ab78d8b | ||
|
|
0b94d1f1e7 | ||
|
|
39245d3e97 | ||
|
|
4a2690e904 | ||
|
|
c7bc68efa2 | ||
|
|
4cc103aa6a | ||
|
|
c1e16d384c | ||
|
|
c3c5c70c42 | ||
|
|
119fc68b8c | ||
|
|
1876949720 | ||
|
|
d1eeff0a9f | ||
|
|
a0d7f70a88 | ||
|
|
90670ddcc4 | ||
|
|
6a0037a88d | ||
|
|
6bb6fb8b96 | ||
|
|
f0b7d2826c | ||
|
|
6f9fd65a0d | ||
|
|
65df8cb2be | ||
|
|
f2fb1b0665 | ||
|
|
753a90a07c | ||
|
|
ee91a7d452 | ||
|
|
b8b41284e2 | ||
|
|
dc2a04c43a | ||
|
|
1f07e96af6 | ||
|
|
41127b9888 | ||
|
|
feb84e5b12 | ||
|
|
b5030fab1a | ||
|
|
c4093838f8 | ||
|
|
4cd00d8c9c | ||
|
|
e93beb9156 | ||
|
|
bf62c68370 | ||
|
|
3465ed3734 | ||
|
|
47f31051e2 | ||
|
|
1a4122062a | ||
|
|
b0a49c6194 | ||
|
|
3603d033d3 | ||
|
|
a05713050f | ||
|
|
3f990b8619 | ||
|
|
4a88124e39 | ||
|
|
4a441f18aa | ||
|
|
dfa928c390 | ||
|
|
023dc40213 | ||
|
|
29e9ebe1a1 | ||
|
|
34a2fa8939 | ||
|
|
b15761af4c | ||
|
|
7ecc54d796 | ||
|
|
e16078a84e | ||
|
|
f20b036b5e | ||
|
|
eb175da8aa | ||
|
|
61c232e30c | ||
|
|
d8041389d4 | ||
|
|
5b8e709ca1 | ||
|
|
09a9ee1623 | ||
|
|
3a5b391dfc | ||
|
|
9bcd91ddba | ||
|
|
90177550fb | ||
|
|
c73ef53002 | ||
|
|
e4e3e4c5fe | ||
|
|
9640939fb8 | ||
|
|
6792bc0192 | ||
|
|
3ce71e611c | ||
|
|
cf8e8cd305 | ||
|
|
4710d7b4bd | ||
|
|
8044a40b50 | ||
|
|
a2b40eb2ac | ||
|
|
298fbe5655 | ||
|
|
3b54f24f18 | ||
|
|
c9a4c041a4 | ||
|
|
5b66a497dd | ||
|
|
0db2a3f46d | ||
|
|
0e92e17f44 | ||
|
|
d3c995fe30 | ||
|
|
025684b5b9 | ||
|
|
9282d04df7 | ||
|
|
3003369b35 | ||
|
|
151da01e57 | ||
|
|
8af1d9a3d4 | ||
|
|
5ffd3e75ae | ||
|
|
11626807de | ||
|
|
ec2501f6a4 | ||
|
|
f8322bcbaa | ||
|
|
40528b4cb5 | ||
|
|
134a638d33 | ||
|
|
b603e0ef0f | ||
|
|
26cf93f6cd | ||
|
|
31084f998b | ||
|
|
64780403e1 |
447 changed files with 46933 additions and 20018 deletions
12
.github/workflows/windows.yml
vendored
12
.github/workflows/windows.yml
vendored
|
|
@ -216,11 +216,13 @@ jobs:
|
|||
run: |
|
||||
$payload = Resolve-Path 'packaging\windows\igneum-windows-app'
|
||||
function Run-Capture([string]$exe, [string]$flag) {
|
||||
$out = Join-Path $env:RUNNER_TEMP ('smoke-' + [IO.Path]::GetRandomFileName() + '.txt')
|
||||
$p = Start-Process -FilePath $exe -ArgumentList $flag -Wait -NoNewWindow -PassThru -RedirectStandardOutput $out
|
||||
$text = if (Test-Path $out) { (Get-Content $out -Raw) } else { '' }
|
||||
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $p.ExitCode, $text.Trim())
|
||||
if ($p.ExitCode -ne 0) { throw "$exe $flag exited $($p.ExitCode)" }
|
||||
# a direct call, not Start-Process -Wait -PassThru: a program that prints and exits at once raced the cmdlet
|
||||
# ("Cannot process request because the process has exited", 7 October 2026, the 0.3.21 run) and the read-back
|
||||
# was lost; the host program is a windows-subsystem exe, so its text comes through the same pipe
|
||||
$text = (& $exe $flag 2>&1 | Out-String)
|
||||
$code = $LASTEXITCODE
|
||||
Write-Host ("{0} {1} -> exit {2}: {3}" -f (Split-Path -Leaf $exe), $flag, $code, $text.Trim())
|
||||
if ($code -ne 0) { throw "$exe $flag exited $code" }
|
||||
return $text
|
||||
}
|
||||
$v = $env:APP_VERSION
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,6 +32,10 @@ vendor/igneum-node-ship/
|
|||
brand/trademark/pbip-pack/
|
||||
brand/trademark/*.zip
|
||||
|
||||
# cargo target dirs of the shipper's local test runs and the build box's fetched artefacts (6 October 2026: a stray add -A staged 450 of them)
|
||||
app/igneum-app/target-tests/
|
||||
proving/igneum-prove/target-remote/
|
||||
**/target-remote/
|
||||
# the Discord bot and reddit bot dry-run renders
|
||||
tools/community/out/
|
||||
# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed
|
||||
|
|
|
|||
2
app/igneum-app/Cargo.lock
generated
2
app/igneum-app/Cargo.lock
generated
|
|
@ -219,7 +219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-app"
|
||||
version = "0.3.14"
|
||||
version = "0.3.22"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "igneum-app"
|
||||
version = "0.3.14"
|
||||
version = "0.3.22"
|
||||
edition = "2021"
|
||||
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
|
||||
license = "MIT"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,3,14,0
|
||||
PRODUCTVERSION 0,3,14,0
|
||||
FILEVERSION 0,3,22,0
|
||||
PRODUCTVERSION 0,3,22,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
|
|
@ -20,12 +20,12 @@ BEGIN
|
|||
BEGIN
|
||||
VALUE "CompanyName", "Igneum"
|
||||
VALUE "FileDescription", "Igneum Miner engine"
|
||||
VALUE "FileVersion", "0.3.14"
|
||||
VALUE "FileVersion", "0.3.22"
|
||||
VALUE "InternalName", "igneum-app"
|
||||
VALUE "LegalCopyright", "Igneum contributors"
|
||||
VALUE "OriginalFilename", "igneum-app.exe"
|
||||
VALUE "ProductName", "Igneum Miner"
|
||||
VALUE "ProductVersion", "0.3.14"
|
||||
VALUE "ProductVersion", "0.3.22"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
|
||||
//! igneum-ota-sign fingerprint <public-key-file|hex>
|
||||
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
|
||||
//! igneum-ota-sign sign-ui <private-key-file> <version> <sha256> <min-engine> the interface entry's own signature
|
||||
//! (src/manifest.rs ui_sign_bytes; tools/ui-ota/publish.mjs calls this), same key
|
||||
//! igneum-ota-sign verify-ui <public-key-file|hex|embedded> <version> <sha256> <min-engine> <signature-hex>
|
||||
//! igneum-ota-sign sign-jobs <private-key-file> <igneum-jobs.json> the remote-jobs file (src/jobs.rs), same key
|
||||
//! igneum-ota-sign verify-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file>
|
||||
//! igneum-ota-sign envelope-jobs <public-key-file|hex> <igneum-jobs.json> <sig-file> prints igneum-jobs.signed.json:
|
||||
|
|
@ -18,6 +21,9 @@
|
|||
//! exit 0 only when the signature, the zip, every
|
||||
//! unpacked file and the pinned commit all check
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[path = "../drivertable.rs"]
|
||||
mod drivertable;
|
||||
#[path = "../manifest.rs"]
|
||||
mod manifest;
|
||||
#[path = "../jobs.rs"]
|
||||
|
|
@ -101,6 +107,26 @@ fn main() {
|
|||
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
||||
println!("{sum} {size}");
|
||||
}
|
||||
Some("sign-ui") if args.len() == 5 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
let sk = SigningKey::from_bytes(&seed);
|
||||
if manifest::parse_version(&args[2]).is_none() || manifest::parse_version(&args[4]).is_none() {
|
||||
die("the version and the engine floor are versions like 0.3.19 or 0.3.19.1");
|
||||
}
|
||||
if args[3].len() != 64 || !args[3].chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
die("the sha256 is 64 hex characters");
|
||||
}
|
||||
println!("{}", manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(&args[2], &args[3], &args[4])).to_bytes()));
|
||||
}
|
||||
Some("verify-ui") if args.len() == 6 => {
|
||||
let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) };
|
||||
let e = manifest::UiEntry { version: args[2].clone(), sha256: args[3].to_ascii_lowercase(), size: 1, url: "https://x".into(), min_engine: args[4].clone(), signature: args[5].to_ascii_lowercase() };
|
||||
match manifest::verify_ui_entry(&e, &pk) {
|
||||
Ok(()) => println!("verifies"),
|
||||
Err(e) => die(&e),
|
||||
}
|
||||
}
|
||||
Some("sign-jobs") if args.len() == 3 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
|
|
|
|||
349
app/igneum-app/src/boot.rs
Normal file
349
app/igneum-app/src/boot.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
//! The engine starts at boot without a logon (0.3.22, MF-11's second half). PC 2, 7 October 2026, 17:27 BST: a kernel
|
||||
//! crash during the Intel driver install, the PC back at 17:28, and the per-user app then waited 67 minutes for a
|
||||
//! logon with every card idle, because the only start was the Run key at logon and `--launch` always opened the window
|
||||
//! host, which has no desktop before a logon.
|
||||
//!
|
||||
//! Windows, from 0.3.22:
|
||||
//! - a per-user scheduled task `Igneum Miner (boot)` runs at system start under the user's own account with the S4U
|
||||
//! logon (no password stored, no logon needed, limited run level): `igneum-app.exe --launch --data-root <the
|
||||
//! user's %LOCALAPPDATA%\igneum>` (the data root is spelled out because an S4U session may not load the profile);
|
||||
//! registered by the engine at its start (unelevated, the user's own task) and again inside the one approved step
|
||||
//! the Power Helper uses, for the account that cannot register it alone;
|
||||
//! - `--launch` decides by the session: no interactive session (SESSIONNAME unset: session 0, the boot task, a
|
||||
//! service) runs the engine headless (`--boot`: no window host, no browser); a logon session opens the window host
|
||||
//! as before;
|
||||
//! - the window host, at logon, starts `igneum-app.exe --wrapper` as always; that engine finds the headless engine's
|
||||
//! app.url answering api/state and becomes a BRIDGE instead of a second engine: it prints the headless engine's
|
||||
//! URL and STATE lines to the host and relays the host's stdin commands (quit, pause, resume, detect) to the
|
||||
//! engine's API. The window closing (stdin gone) ends the bridge and leaves the engine mining; Quit in the tray
|
||||
//! quits the engine. A headless engine that stops answering ends the bridge with EXIT, and the host (0.3.21)
|
||||
//! starts `--wrapper` again, which then runs as a full engine.
|
||||
//! The decisions are functions here so the tests drive them with the known-failed shape first.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The boot task's name in the Windows Task Scheduler (per user).
|
||||
pub const TASK_NAME: &str = "Igneum Miner (boot)";
|
||||
/// How often the bridge reads api/state for a STATE line, and how many misses in a row end it.
|
||||
pub const BRIDGE_POLL_S: u64 = 3;
|
||||
pub const BRIDGE_MISSES: u32 = 4;
|
||||
|
||||
/// What `--launch` does.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchMode {
|
||||
/// start "Igneum Miner.exe" (the window host), which starts the engine
|
||||
Host,
|
||||
/// run the engine here, no window host, no browser (no interactive session, or no host next to the exe)
|
||||
Headless,
|
||||
/// run the engine here and open the dashboard in the default browser (a logon session without the window host)
|
||||
Browser,
|
||||
}
|
||||
|
||||
/// The session a process runs in: `SESSIONNAME` is set in every interactive logon session ("Console", "RDP-Tcp#3")
|
||||
/// and unset in session 0 (services, S4U scheduled tasks at boot).
|
||||
pub fn interactive_session(session_name: Option<&str>) -> bool {
|
||||
session_name.map(|s| !s.trim().is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 0.3.22: the window host only when someone is logged on; headless otherwise.
|
||||
pub fn launch_mode(session_name: Option<&str>, host_exists: bool) -> LaunchMode {
|
||||
match (interactive_session(session_name), host_exists) {
|
||||
(true, true) => LaunchMode::Host,
|
||||
(true, false) => LaunchMode::Browser,
|
||||
(false, _) => LaunchMode::Headless,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before 0.3.22, for the record: the host whenever it existed, whoever was or was not logged on.
|
||||
pub fn legacy_launch_mode(host_exists: bool) -> LaunchMode {
|
||||
if host_exists { LaunchMode::Host } else { LaunchMode::Browser }
|
||||
}
|
||||
|
||||
/// Does a closed stdin mean "the host went away" (quit)? Only for an engine a window host attached (`--wrapper`): a
|
||||
/// headless engine (`--boot`, the boot task, a job's `--launch` with no session) owns itself and has no host to lose.
|
||||
/// PC 2, 7 October 2026, 19:09 to 19:11 BST: four engines started a minute apart each quit 3 s after "node started" with
|
||||
/// "the window host went away (stdin closed)", their parent having closed the pipe at once.
|
||||
pub fn stdin_close_quits(wrapper: bool, headless: bool) -> bool {
|
||||
wrapper && !headless
|
||||
}
|
||||
|
||||
// ---- the boot task ---------------------------------------------------------------------------------------------------
|
||||
|
||||
fn ps_quote(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// The PowerShell that registers the boot task: trigger at system start, principal the signed-in user with the S4U
|
||||
/// logon (runs with nobody logged on, no password stored), limited run level, no time limit, one instance, hidden;
|
||||
/// the action is the installed exe with `--launch --data-root <root>`.
|
||||
pub fn register_script(exe: &Path, data_root: &Path) -> String {
|
||||
let exe_s = ps_quote(&exe.display().to_string());
|
||||
let dir = exe.parent().map(|d| ps_quote(&d.display().to_string())).unwrap_or_default();
|
||||
let root = ps_quote(&data_root.display().to_string());
|
||||
format!(
|
||||
"$a = New-ScheduledTaskAction -Execute '{exe_s}' -Argument '--launch --data-root \"{root}\"' -WorkingDirectory '{dir}'\r\n\
|
||||
$t = New-ScheduledTaskTrigger -AtStartup\r\n\
|
||||
$t.Delay = 'PT30S'\r\n\
|
||||
$p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited\r\n\
|
||||
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew -StartWhenAvailable -Hidden\r\n\
|
||||
Register-ScheduledTask -TaskName '{name}' -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null\r\n\
|
||||
exit 0\r\n",
|
||||
name = TASK_NAME
|
||||
)
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the boot task is registered, enabled and its action's exe present (exit 0).
|
||||
pub fn query_command() -> String {
|
||||
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell that removes the task (an uninstall, or Start at login switched off).
|
||||
pub fn remove_command() -> String {
|
||||
format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue; exit 0")
|
||||
}
|
||||
|
||||
fn powershell(command: &str, limit: Duration) -> Option<(bool, String)> {
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = crate::detect::run_timeout(&mut c, None, limit)?;
|
||||
// run_timeout folds the streams; the exit code is read again through a second probe when needed
|
||||
Some((true, out))
|
||||
}
|
||||
|
||||
/// Is the boot task registered (Windows only; false elsewhere)?
|
||||
pub fn registered() -> bool {
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]);
|
||||
crate::platform::quiet(&mut c);
|
||||
c.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Registers the boot task unelevated (the user's own task). Ok(true) registered now, Ok(false) already there,
|
||||
/// Err(why) when Windows refused (an account without the batch-logon right: the one approved step registers it).
|
||||
pub fn ensure_registered(exe: &Path, data_root: &Path) -> Result<bool, String> {
|
||||
if !cfg!(windows) {
|
||||
return Ok(false);
|
||||
}
|
||||
if registered() {
|
||||
return Ok(false);
|
||||
}
|
||||
let script = register_script(exe, data_root);
|
||||
let (_, out) = powershell(&script, Duration::from_secs(60)).ok_or("powershell did not answer")?;
|
||||
if registered() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!("the boot task was not registered: {}", out.lines().last().unwrap_or("no reason given").trim()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed exe the task's action names (never a scratch copy), as the Power Helper picks it.
|
||||
pub fn task_exe(running: &Path) -> PathBuf {
|
||||
crate::powertask::task_exe(running, &crate::powertask::install_candidates())
|
||||
}
|
||||
|
||||
// ---- the bridge -------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// The engine already running under the user's data root: its URL when app.url names one that answers api/state.
|
||||
pub fn running_engine(app_dir: &Path) -> Option<String> {
|
||||
let url = std::fs::read_to_string(app_dir.join("app.url")).ok()?.trim().to_string();
|
||||
if !url.starts_with("http://127.0.0.1:") {
|
||||
return None;
|
||||
}
|
||||
let state = api_get(&url, "api/state")?;
|
||||
let v: serde_json::Value = serde_json::from_str(state.trim()).ok()?;
|
||||
v.get("version").and_then(|x| x.as_str()).map(|_| url)
|
||||
}
|
||||
|
||||
fn api_get(url: &str, path: &str) -> Option<String> {
|
||||
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "4", &format!("{url}{path}")]), None, Duration::from_secs(6)).filter(|o| !o.trim().is_empty())
|
||||
}
|
||||
|
||||
fn api_post(url: &str, path: &str) -> bool {
|
||||
crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "6", "-X", "POST", "-H", "Content-Type: application/json", "-d", "{}", &format!("{url}{path}")]), None, Duration::from_secs(8)).is_some()
|
||||
}
|
||||
|
||||
/// The host's stdin line as the engine's API path; None for a line the bridge does not relay.
|
||||
pub fn command_path(line: &str) -> Option<&'static str> {
|
||||
match line.trim() {
|
||||
"quit" => Some("api/quit"),
|
||||
"pause" => Some("api/pause"),
|
||||
"resume" => Some("api/resume"),
|
||||
"detect" => Some("api/detect"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The STATE line the host reads (engine.rs wrapper_state), built from the engine's api/state reply.
|
||||
pub fn state_line(api_state: &serde_json::Value) -> String {
|
||||
let g = |p: &[&str]| -> serde_json::Value {
|
||||
let mut v = api_state;
|
||||
for k in p {
|
||||
v = match v.get(k) {
|
||||
Some(x) => x,
|
||||
None => return serde_json::Value::Null,
|
||||
};
|
||||
}
|
||||
v.clone()
|
||||
};
|
||||
serde_json::json!({
|
||||
"phase": g(&["phase"]), "mining": g(&["mining", "state"]), "paused": g(&["mining", "paused"]),
|
||||
"hash_total": g(&["mining", "hash_total"]), "accepted_total": g(&["mining", "accepted_total"]),
|
||||
"node": g(&["node", "state"]), "blocks": g(&["node", "blocks"]), "peers": g(&["node", "peers"]), "quitting": g(&["quitting"]),
|
||||
"update": g(&["update", "available"]), "bridge": true,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What the bridge does after one poll: carry on, or end (with EXIT when the engine is gone; silently when the host
|
||||
/// closed its end, which leaves the engine mining).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum BridgeStep {
|
||||
Continue,
|
||||
EngineGone,
|
||||
HostGone,
|
||||
}
|
||||
|
||||
pub fn bridge_step(misses: u32, stdin_closed: bool) -> BridgeStep {
|
||||
if stdin_closed {
|
||||
BridgeStep::HostGone
|
||||
} else if misses >= BRIDGE_MISSES {
|
||||
BridgeStep::EngineGone
|
||||
} else {
|
||||
BridgeStep::Continue
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the bridge until the host or the engine goes. Returns the process exit code.
|
||||
pub fn run_bridge(url: &str) -> i32 {
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
println!("URL {url}");
|
||||
let _ = std::io::stdout().flush();
|
||||
let closed = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let closed = closed.clone();
|
||||
let url = url.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
for line in stdin.lock().lines() {
|
||||
let Ok(l) = line else { break };
|
||||
if let Some(p) = command_path(&l) {
|
||||
let _ = api_post(&url, p);
|
||||
}
|
||||
}
|
||||
closed.store(true, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
let mut misses = 0u32;
|
||||
loop {
|
||||
match api_get(url, "api/state").and_then(|s| serde_json::from_str::<serde_json::Value>(s.trim()).ok()) {
|
||||
Some(v) => {
|
||||
misses = 0;
|
||||
println!("STATE {}", state_line(&v));
|
||||
let _ = std::io::stdout().flush();
|
||||
if v.get("quitting").and_then(|q| q.as_bool()).unwrap_or(false) {
|
||||
// the engine is leaving (Quit from the tray relayed above, or its own update): the host wants EXIT
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
misses = BRIDGE_MISSES;
|
||||
}
|
||||
}
|
||||
None => misses += 1,
|
||||
}
|
||||
match bridge_step(misses, closed.load(Ordering::Relaxed)) {
|
||||
BridgeStep::Continue => std::thread::sleep(Duration::from_secs(BRIDGE_POLL_S)),
|
||||
BridgeStep::EngineGone => {
|
||||
println!("EXIT");
|
||||
let _ = std::io::stdout().flush();
|
||||
return 0;
|
||||
}
|
||||
BridgeStep::HostGone => return 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Known-failed first: before 0.3.22 a boot with nobody logged on opened the window host (no desktop, no engine);
|
||||
/// PC 2 sat 67 minutes idle after its 17:28 BST self-reboot on 7 October 2026.
|
||||
#[test]
|
||||
fn before_0322_no_logon_meant_the_host_and_no_engine() {
|
||||
assert_eq!(legacy_launch_mode(true), LaunchMode::Host, "the host regardless of the session");
|
||||
assert_eq!(launch_mode(None, true), LaunchMode::Headless, "0.3.22: no session, the engine runs headless");
|
||||
assert_eq!(launch_mode(Some(""), true), LaunchMode::Headless);
|
||||
}
|
||||
|
||||
/// Known-failed first: before 0.3.22 every `--wrapper` engine quit on a closed stdin, whoever had started it.
|
||||
#[test]
|
||||
fn a_headless_engine_never_reads_a_closed_stdin_as_the_host_leaving() {
|
||||
assert!(stdin_close_quits(true, false), "the window host's own engine: the host left, the engine follows (by design)");
|
||||
assert!(!stdin_close_quits(true, true), "0.3.22: headless, even with --wrapper on the line, stays up");
|
||||
assert!(!stdin_close_quits(false, false), "an engine with no host never had a stdin to lose");
|
||||
assert!(!stdin_close_quits(false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logon_session_keeps_the_window_host_or_the_browser() {
|
||||
assert_eq!(launch_mode(Some("Console"), true), LaunchMode::Host);
|
||||
assert_eq!(launch_mode(Some("RDP-Tcp#3"), true), LaunchMode::Host);
|
||||
assert_eq!(launch_mode(Some("Console"), false), LaunchMode::Browser);
|
||||
assert!(interactive_session(Some("Console")) && !interactive_session(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boot_task_runs_at_startup_as_the_user_without_a_logon_and_names_the_data_root() {
|
||||
let s = register_script(Path::new("C:\\p\\Igneum Miner\\igneum-app.exe"), Path::new("C:\\u\\AppData\\Local\\igneum"));
|
||||
assert!(s.contains("-Execute 'C:\\p\\Igneum Miner\\igneum-app.exe' -Argument '--launch --data-root \"C:\\u\\AppData\\Local\\igneum\"'"), "{s}");
|
||||
assert!(s.contains("New-ScheduledTaskTrigger -AtStartup"), "at system start, not at logon");
|
||||
assert!(s.contains("-LogonType S4U -RunLevel Limited"), "the user's own token with nobody logged on, never elevated");
|
||||
assert!(s.contains("-ExecutionTimeLimit (New-TimeSpan -Seconds 0)") && s.contains("-MultipleInstances IgnoreNew") && s.contains("-Hidden"));
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
assert!(!s.contains("powershell.exe") && !s.contains("cmd /c"), "the action is the exe itself: no console window at boot");
|
||||
let q = query_command();
|
||||
assert!(q.contains("Test-Path") && q.contains("-ne 'Disabled'") && q.contains("exit 1"), "{q}");
|
||||
assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Miner (boot)'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_relays_the_hosts_commands_and_nothing_else() {
|
||||
assert_eq!(command_path("quit"), Some("api/quit"));
|
||||
assert_eq!(command_path(" pause \r"), Some("api/pause"));
|
||||
assert_eq!(command_path("resume"), Some("api/resume"));
|
||||
assert_eq!(command_path("detect"), Some("api/detect"));
|
||||
assert_eq!(command_path("elevated ok"), None, "the elevated answers belong to a real engine's host");
|
||||
assert_eq!(command_path("rm -rf"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_state_line_is_the_wrapper_state() {
|
||||
let st: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22","phase":"dashboard","mining":{"state":"mining","paused":false,"hash_total":121.0,"accepted_total":95511},"node":{"state":"synced","blocks":165529,"peers":4},"quitting":false,"update":{"available":false}}"#).unwrap();
|
||||
let line: serde_json::Value = serde_json::from_str(&state_line(&st)).unwrap();
|
||||
assert_eq!(line["phase"], "dashboard");
|
||||
assert_eq!(line["mining"], "mining");
|
||||
assert_eq!(line["hash_total"], 121.0);
|
||||
assert_eq!(line["accepted_total"], 95511);
|
||||
assert_eq!(line["node"], "synced");
|
||||
assert_eq!(line["blocks"], 165529);
|
||||
assert_eq!(line["quitting"], false);
|
||||
assert_eq!(line["bridge"], true);
|
||||
let partial: serde_json::Value = serde_json::from_str(r#"{"version":"0.3.22"}"#).unwrap();
|
||||
assert!(state_line(&partial).contains("\"phase\":null"), "a missing field is null, never a panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_ends_with_exit_when_the_engine_is_gone_and_silently_when_the_host_is() {
|
||||
assert_eq!(bridge_step(0, false), BridgeStep::Continue);
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES - 1, false), BridgeStep::Continue);
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES, false), BridgeStep::EngineGone, "the host then starts a fresh --wrapper, which becomes a full engine");
|
||||
assert_eq!(bridge_step(0, true), BridgeStep::HostGone, "the window closed: the headless engine keeps mining");
|
||||
assert_eq!(bridge_step(BRIDGE_MISSES, true), BridgeStep::HostGone);
|
||||
}
|
||||
}
|
||||
73
app/igneum-app/src/bootcheck.rs
Normal file
73
app/igneum-app/src/bootcheck.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! Did this PC come up from a power loss or a hard reset? (MF-11, 7 October 2026: PC 2 dropped twice in one day with
|
||||
//! Kernel-Power 41 and EventLog 6008 at the next boot, no bugcheck, no dump, and nothing said so until a person read
|
||||
//! the event log.) Windows: the System log's event 41 (Kernel-Power, critical) or 6008 (EventLog, "the previous
|
||||
//! shutdown was unexpected") inside the last 15 minutes, read once at the engine's start through wevtutil; the engine
|
||||
//! logs one `FAULT pc-restart:` line to the intake. Other platforms: nothing (a Mac's power log is not this class).
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How far back the start-up check looks: an engine starts at login, inside a minute or two of the boot.
|
||||
pub const WINDOW_MS: u64 = 15 * 60 * 1000;
|
||||
|
||||
/// One line naming the event, or None when the boot was clean, the query failed, or this is not Windows.
|
||||
pub fn unexpected_restart() -> Option<String> {
|
||||
if !cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let query = format!("*[System[(EventID=41 or EventID=6008) and TimeCreated[timediff(@SystemTime) <= {WINDOW_MS}]]]");
|
||||
let mut c = Command::new(crate::platform::tool("wevtutil"));
|
||||
c.args(["qe", "System", &format!("/q:{query}"), "/f:text", "/c:2", "/rd:true"]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(20))?;
|
||||
parse_events(&out)
|
||||
}
|
||||
|
||||
/// The reading of wevtutil's text output: the newest 41 or 6008 as "event 41 (Kernel-Power) at <time>" or
|
||||
/// "event 6008 (the previous shutdown was unexpected) at <time>"; None when neither is in the text.
|
||||
pub fn parse_events(text: &str) -> Option<String> {
|
||||
let mut date = String::new();
|
||||
let mut id = String::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if let Some(d) = t.strip_prefix("Date:") {
|
||||
date = d.trim().to_string();
|
||||
} else if let Some(i) = t.strip_prefix("Event ID:") {
|
||||
id = i.trim().to_string();
|
||||
if id == "41" || id == "6008" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match id.as_str() {
|
||||
"41" => Some(format!("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at {date}")),
|
||||
"6008" => Some(format!("event 6008 (the previous shutdown was unexpected) at {date}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_events;
|
||||
|
||||
const PC2: &str = "Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-Power\n Date: 2026-10-07T14:37:19.4360000Z\n Event ID: 41\n Task: N/A\n Level: Critical\n Opcode: Info\n Keyword: N/A\n User: S-1-5-18\n Computer: X\n Description: \n\nEvent[1]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T14:37:29.0380000Z\n Event ID: 6008\n Task: None\n Level: Error\n";
|
||||
|
||||
/// PC 2's boot of 13:37Z on 7 October 2026 (the collection job's wevtutil text): the known-failed case reads as one.
|
||||
#[test]
|
||||
fn pc2_boot_reads_as_a_power_loss() {
|
||||
assert_eq!(parse_events(PC2), Some("event 41 (Kernel-Power: the system rebooted without shutting down cleanly) at 2026-10-07T14:37:19.4360000Z".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_6008_alone_is_the_unexpected_shutdown() {
|
||||
let t = "Event[0]\n Log Name: System\n Source: EventLog\n Date: 2026-10-07T08:22:34.0000000Z\n Event ID: 6008\n Level: Error\n";
|
||||
assert_eq!(parse_events(t), Some("event 6008 (the previous shutdown was unexpected) at 2026-10-07T08:22:34.0000000Z".into()));
|
||||
}
|
||||
|
||||
/// A clean boot (the known-good case): nothing, including other ids inside the window and an empty answer.
|
||||
#[test]
|
||||
fn a_clean_boot_reads_as_nothing() {
|
||||
assert_eq!(parse_events(""), None);
|
||||
assert_eq!(parse_events("Event[0]\n Log Name: System\n Source: Microsoft-Windows-Kernel-General\n Date: 2026-10-07T14:37:17.7400000Z\n Event ID: 12\n Level: Information\n"), None);
|
||||
assert_eq!(parse_events("wevtutil: access denied"), None);
|
||||
}
|
||||
}
|
||||
80
app/igneum-app/src/card.rs
Normal file
80
app/igneum-app/src/card.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card
|
||||
//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under
|
||||
//! `<data root>/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL.
|
||||
pub fn decode_data_url(s: &str) -> Option<Vec<u8>> {
|
||||
let rest = s.strip_prefix("data:image/png;base64,")?;
|
||||
let bytes = decode_base64(rest)?;
|
||||
if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character.
|
||||
pub fn decode_base64(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0u32;
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `<root>/cards/igneum-block-<name>-<unix>.png`, the name reduced to [a-z0-9-] and at most 40 characters.
|
||||
pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf {
|
||||
let mut clean = String::new();
|
||||
for c in name.to_ascii_lowercase().chars() {
|
||||
if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); }
|
||||
}
|
||||
let clean: String = clean.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string();
|
||||
let clean = if clean.is_empty() { "block".to_string() } else { clean };
|
||||
root.join("cards").join(format!("igneum-block-{clean}-{unix}.png"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_with_and_without_padding() {
|
||||
assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world");
|
||||
assert!(decode_base64("aGV$sbG8=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_data_url_decodes_and_anything_else_is_refused() {
|
||||
// the 8-byte PNG signature, base64
|
||||
let sig = "iVBORw0KGgo=";
|
||||
let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap();
|
||||
assert_eq!(&png[..4], b"\x89PNG");
|
||||
assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none());
|
||||
assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_name_is_safe_and_dated() {
|
||||
let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116);
|
||||
assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png"));
|
||||
assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png");
|
||||
}
|
||||
}
|
||||
263
app/igneum-app/src/chainfacts.rs
Normal file
263
app/igneum-app/src/chainfacts.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard
|
||||
//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key
|
||||
//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over
|
||||
//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints`
|
||||
//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public
|
||||
//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same
|
||||
//! node RPCs.
|
||||
//!
|
||||
//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality
|
||||
//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose
|
||||
//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids
|
||||
//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s.
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(10);
|
||||
/// the observer relays at most this many keys (tools/observer/observer.mjs)
|
||||
pub const OBSERVER_KEYS_CAP: usize = 64;
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(s) => s.parse().unwrap_or(0.0),
|
||||
Value::Bool(b) => if *b { 1.0 } else { 0.0 },
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 }
|
||||
fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value {
|
||||
for k in keys {
|
||||
if let Some(x) = v.get(k) { return x; }
|
||||
}
|
||||
&Value::Null
|
||||
}
|
||||
|
||||
/// One key's row in either spelling (the node's camelCase or the observer's snake case).
|
||||
fn key_row(k: &Value) -> Value {
|
||||
let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase();
|
||||
let id: String = id_full.chars().take(8).collect();
|
||||
let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) };
|
||||
json!({
|
||||
"id": id,
|
||||
"blocks": u(get(k, &["blocks"])),
|
||||
"voter": get(k, &["voter"]).as_bool().unwrap_or(false),
|
||||
"participation": num(get(k, &["participation"])),
|
||||
"stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])),
|
||||
"revealed": revealed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a
|
||||
/// source may keep more or fewer).
|
||||
pub fn is_mine(id: &str, ids: &[String]) -> bool {
|
||||
let id = id.trim_start_matches("0x").to_ascii_lowercase();
|
||||
ids.iter().any(|m| {
|
||||
let m = m.trim_start_matches("0x").to_ascii_lowercase();
|
||||
let n = id.len().min(m.len());
|
||||
n >= 6 && id[..n] == m[..n]
|
||||
})
|
||||
}
|
||||
|
||||
/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's
|
||||
/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the
|
||||
/// public stats, and this machine's key ids. Pure.
|
||||
pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value {
|
||||
let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) };
|
||||
let mut keys: Vec<Value> = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default();
|
||||
keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str())));
|
||||
let mut mine: Vec<Value> = Vec::new();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if is_mine(k["id"].as_str().unwrap_or(""), ids) {
|
||||
let mut m = k.clone();
|
||||
m["rank"] = json!(i + 1);
|
||||
mine.push(m);
|
||||
}
|
||||
}
|
||||
let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned();
|
||||
let reward = get(stats, &["block_reward"]);
|
||||
let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) };
|
||||
let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) };
|
||||
json!({
|
||||
"ok": true,
|
||||
"source": source,
|
||||
"read_at": read_at,
|
||||
"rpc": {
|
||||
"weights": "getFinalityWeights",
|
||||
"checkpoints": "getFinalityCheckpoints",
|
||||
"stats": "/api/stats",
|
||||
"relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" }
|
||||
},
|
||||
"params": {
|
||||
"dust": u(get(params, &["dust"])),
|
||||
"weight_window": u(get(params, &["weightWindow", "weight_window"])),
|
||||
"presence_window": u(get(params, &["presenceWindow", "presence_window"])),
|
||||
"checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])),
|
||||
"min_daa": u(get(params, &["minDaa", "min_daa"])),
|
||||
},
|
||||
"checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])),
|
||||
"daa_score": u(get(w, &["daaScore", "daa_score"])),
|
||||
"latest_locked_index": latest_locked,
|
||||
"next_index": u(get(finality, &["nextIndex", "next_index"])),
|
||||
"finality_active": active,
|
||||
"total_weight": u(get(w, &["totalWeight", "total_weight"])),
|
||||
"active_weight": num(get(w, &["activeWeight", "active_weight"])),
|
||||
"voters": u(get(w, &["voters"])),
|
||||
"keys_listed": keys.len(),
|
||||
"keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP },
|
||||
"network": {
|
||||
"hashrate_hps": num(get(stats, &["hashrate"])),
|
||||
"miner_ign_per_block": num(get(reward, &["miner_ign"])),
|
||||
"ign_per_block": num(get(reward, &["ign"])),
|
||||
"ramp_factor": num(get(reward, &["ramp_factor"])),
|
||||
"daa": u(get(stats, &["daa"])),
|
||||
"blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])),
|
||||
"bps": 1,
|
||||
"stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()),
|
||||
},
|
||||
"mine": mine,
|
||||
"best": best,
|
||||
})
|
||||
}
|
||||
|
||||
fn curl_json(url: &str) -> Option<Value> {
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?;
|
||||
serde_json::from_str(out.trim()).ok()
|
||||
}
|
||||
|
||||
/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats`
|
||||
pub fn stats_api_from(live_api: &str) -> String {
|
||||
match live_api.strip_suffix("/api/live") {
|
||||
Some(base) => format!("{base}/api/stats"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when
|
||||
/// neither answers.
|
||||
pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value {
|
||||
if let Some((at, v)) = CACHE.lock().unwrap().as_ref() {
|
||||
if at.elapsed() < TTL {
|
||||
let mut c = v.clone();
|
||||
c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round());
|
||||
return c;
|
||||
}
|
||||
}
|
||||
let now = crate::platform::unix_now_f();
|
||||
let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) };
|
||||
let stats = stats.unwrap_or(Value::Null);
|
||||
let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) {
|
||||
Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now),
|
||||
_ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } {
|
||||
Some(live) => {
|
||||
let fin = get(&live, &["finality"]);
|
||||
let w = get(fin, &["weights"]);
|
||||
if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) }
|
||||
}
|
||||
None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }),
|
||||
},
|
||||
};
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), reply.clone()));
|
||||
reply
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn observer_live() -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"state": { "hashes_per_second_estimate": 764944722 },
|
||||
"finality": {
|
||||
"supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495,
|
||||
"params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 },
|
||||
"weights": {
|
||||
"checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14,
|
||||
"keys": [
|
||||
{ "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 },
|
||||
{ "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn stats() -> Value {
|
||||
json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0);
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["source"], "observer");
|
||||
assert_eq!(r["params"]["dust"], 5);
|
||||
assert_eq!(r["params"]["weight_window"], 7200);
|
||||
assert_eq!(r["params"]["presence_window"], 20);
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
assert_eq!(r["finality_active"], true);
|
||||
assert_eq!(r["voters"], 14);
|
||||
assert_eq!(r["keys_listed"], 4);
|
||||
assert_eq!(r["keys_cap"], 64);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 764944722.0);
|
||||
assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["mine"][0]["rank"], 3);
|
||||
assert_eq!(r["mine"][0]["blocks"], 61);
|
||||
assert_eq!(r["mine"][0]["voter"], true);
|
||||
assert_eq!(r["best"]["id"], "8fafda27");
|
||||
assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() {
|
||||
let w = json!({
|
||||
"params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 },
|
||||
"checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204,
|
||||
"keys": [
|
||||
{ "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 },
|
||||
{ "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 }
|
||||
]
|
||||
});
|
||||
let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0);
|
||||
assert_eq!(r["source"], "node");
|
||||
assert_eq!(r["keys_cap"], 0);
|
||||
assert_eq!(r["params"]["dust"], 100);
|
||||
assert_eq!(r["params"]["weight_window"], 2592000);
|
||||
assert_eq!(r["checkpoint_index"], 184220);
|
||||
assert_eq!(r["mine"][0]["id"], "8fafda27");
|
||||
assert_eq!(r["mine"][0]["rank"], 1);
|
||||
assert_eq!(r["mine"][0]["revealed"], true);
|
||||
assert_eq!(r["rpc"]["relay"], "");
|
||||
// the locked index falls back to the public stats when the node reply carries no checkpoints block
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_key_in_the_table_has_no_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 0);
|
||||
assert!(r["best"].is_null());
|
||||
assert_eq!(r["network"]["stale"], true);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matching_takes_a_prefix_of_six_or_more() {
|
||||
assert!(is_mine("8fafda27", &["8fafda27aa11".into()]));
|
||||
assert!(is_mine("0x8fafda27aa", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafd", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafda28", &["8fafda27".into()]));
|
||||
assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats");
|
||||
assert_eq!(stats_api_from(""), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,18 @@ pub struct CardPref {
|
|||
pub sweep_class: String,
|
||||
#[serde(default)]
|
||||
pub sweep_source: String,
|
||||
/// the chosen clock sat on the ladder's floor (the lowest step measured, not the optimum)
|
||||
#[serde(default)]
|
||||
pub sweep_floor: bool,
|
||||
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
|
||||
#[serde(default)]
|
||||
pub tune_goal: String,
|
||||
/// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can
|
||||
/// read "saves 84 W, 0.15% of rate"; 0 = never measured
|
||||
#[serde(default)]
|
||||
pub sweep_before_watts: f64,
|
||||
#[serde(default)]
|
||||
pub sweep_before_mhs: f64,
|
||||
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
|
||||
#[serde(default)]
|
||||
pub sweep_mem_mhz: u32,
|
||||
|
|
@ -102,6 +114,31 @@ pub struct Settings {
|
|||
pub power_price_pence: f64,
|
||||
#[serde(default)]
|
||||
pub tune_climb: bool,
|
||||
/// Ember Heat (mission item 7, 7 October 2026; src/heat.rs): the region code the miner chose at first run or in
|
||||
/// Settings ("" = not chosen; the price above is in that region's minor unit per kWh, typed, never fetched), the
|
||||
/// heat-mode switch, the set point in degrees, the schedule (slots by minute of the day in the window's clock,
|
||||
/// `heat_tz_min` minutes east of UTC), the typed room reading and when it was typed (0 = none), and the learned
|
||||
/// idle offset of the card sensor above the room (0 = the default).
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
/// currency-21 (7 October 2026): the ISO 4217 code the miner chose in Settings ("" = follow the region, which
|
||||
/// follows the OS locale at first run); the price above is in hundredths of this unit per kWh. Survives restart here.
|
||||
#[serde(default)]
|
||||
pub currency: String,
|
||||
#[serde(default)]
|
||||
pub heat_on: bool,
|
||||
#[serde(default = "nineteen")]
|
||||
pub heat_set_c: f64,
|
||||
#[serde(default)]
|
||||
pub heat_schedule: Vec<crate::heat::Slot>,
|
||||
#[serde(default)]
|
||||
pub heat_tz_min: i32,
|
||||
#[serde(default)]
|
||||
pub heat_room_c: f64,
|
||||
#[serde(default)]
|
||||
pub heat_room_at: f64,
|
||||
#[serde(default)]
|
||||
pub heat_offset_c: f64,
|
||||
/// When this install first ran (unix s), for the "first hour after install" sweep.
|
||||
#[serde(default)]
|
||||
pub installed_at: u64,
|
||||
|
|
@ -120,6 +157,18 @@ pub struct Settings {
|
|||
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
|
||||
#[serde(default)]
|
||||
pub prove_default_applied: bool,
|
||||
/// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and
|
||||
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
|
||||
#[serde(default)]
|
||||
pub profile_public: bool,
|
||||
/// ui-ota (7 October 2026, src/uiota.rs): "Use the built-in interface": the embedded dashboard serves even when an
|
||||
/// over-the-air interface bundle is active. Default off.
|
||||
#[serde(default)]
|
||||
pub ui_builtin: bool,
|
||||
/// Prove instead of mining on a machine whose only NVIDIA card is under 12 GB (main's routing, 7 October 2026: a
|
||||
/// 10 GB card holds the prover or the miner, never both). Off by default; the 12 GB refusal stays while it is off.
|
||||
#[serde(default)]
|
||||
pub prove_instead: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -128,13 +177,16 @@ fn one() -> u32 {
|
|||
fn balanced() -> String {
|
||||
"balanced".into()
|
||||
}
|
||||
fn nineteen() -> f64 {
|
||||
19.0
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Settings {
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false, prove_instead: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +278,14 @@ pub struct Packaged {
|
|||
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
|
||||
#[serde(default)]
|
||||
pub node_override_params: Option<serde_json::Value>,
|
||||
/// The network the package's node joins (7 October 2026, Devnet 3): a suffixed devnet (`--devnet-suffix=N`), its own
|
||||
/// genesis and p2p port, its own node datadir devnet-N beside the shared devnet's devnet-v4 (kept for the way back).
|
||||
/// Absent = the shared devnet. IGNEUM_APP_DEVNET_SUFFIX in the environment wins over it.
|
||||
#[serde(default)]
|
||||
pub node_devnet_suffix: Option<u32>,
|
||||
/// The package's default peers for that network (host:port); absent = the shared devnet's list.
|
||||
#[serde(default)]
|
||||
pub node_peers: Option<Vec<String>>,
|
||||
/// Where the key and the manifest came from, for the log header: "packaged", "file <name>" or "none". Never
|
||||
/// serialised (the packaged file does not carry them; nothing sends this struct to the UI).
|
||||
#[serde(skip)]
|
||||
|
|
@ -351,18 +411,25 @@ pub struct Runtime {
|
|||
|
||||
impl Runtime {
|
||||
pub fn from_env() -> Runtime {
|
||||
Self::from_env_with(None, None)
|
||||
}
|
||||
|
||||
/// `packaged_suffix` and `packaged_peers` are the package's network (config.rs Packaged); the environment wins.
|
||||
pub fn from_env_with(packaged_suffix: Option<u32>, packaged_peers: Option<&[String]>) -> Runtime {
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let network = env("IGNEUM_APP_NETWORK").unwrap_or_else(|| "devnet".into());
|
||||
let rpc_port = env("IGNEUM_APP_RPC_PORT").and_then(|v| v.parse().ok()).unwrap_or(26610);
|
||||
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
|
||||
let peers = match std::env::var("IGNEUM_APP_PEERS") {
|
||||
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||
// the public seed node first, then the project lead's Mac on the house LAN (devnet only)
|
||||
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()],
|
||||
// the public seed node first, then the build box's hand nodes (main's decision (b), 6 October 2026, 19:1x UTC:
|
||||
// the hands move off the project lead's Mac to igneum-build-1), then the project lead's Mac on the house LAN (devnet only)
|
||||
Err(_) if packaged_peers.map(|p| !p.is_empty()).unwrap_or(false) => packaged_peers.unwrap().to_vec(),
|
||||
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()],
|
||||
Err(_) => vec![],
|
||||
};
|
||||
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
|
||||
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok());
|
||||
let devnet_suffix = env("IGNEUM_APP_DEVNET_SUFFIX").and_then(|v| v.parse().ok()).or(packaged_suffix);
|
||||
let root = crate::platform::data_root();
|
||||
let node_dir = match env("IGNEUM_APP_NODE_DIR") {
|
||||
Some(d) => PathBuf::from(d),
|
||||
|
|
@ -392,6 +459,20 @@ impl Runtime {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn currency_round_trip() {
|
||||
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
|
||||
let mut s = super::Settings::default();
|
||||
s.currency = "EUR".into();
|
||||
s.region = "de".into();
|
||||
let text = serde_json::to_string(&s).unwrap();
|
||||
let back: super::Settings = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(back.currency, "EUR");
|
||||
assert_eq!(back.region, "de");
|
||||
let old: super::Settings = serde_json::from_str(r#"{"region":"gb"}"#).unwrap();
|
||||
assert_eq!(old.currency, "");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tmp(name: &str, content: &str) -> PathBuf {
|
||||
|
|
@ -529,4 +610,19 @@ mod fixture_tests {
|
|||
Err(e) => panic!("the crate refuses the file: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_package_names_the_network_when_the_environment_is_silent() {
|
||||
use super::Runtime;
|
||||
// the test never sets IGNEUM_APP_DEVNET_SUFFIX or IGNEUM_APP_PEERS, so the package's values win
|
||||
let peers = vec!["188.40.146.49:26631".to_string(), "188.40.146.49:26671".to_string()];
|
||||
let r = Runtime::from_env_with(Some(3), Some(&peers));
|
||||
assert_eq!(r.devnet_suffix, Some(3));
|
||||
assert_eq!(r.peers, peers);
|
||||
assert!(r.node_dir.ends_with("devnet-3"), "{}", r.node_dir.display());
|
||||
// no package network: the shared devnet as before
|
||||
let r = Runtime::from_env_with(None, None);
|
||||
assert_eq!(r.devnet_suffix, None);
|
||||
assert!(r.node_dir.ends_with("devnet-v4"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,8 +111,11 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
|
|||
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
|
||||
pub fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("arc") && n.contains("intel") {
|
||||
return arc_is_integrated(&n);
|
||||
}
|
||||
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
|
||||
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
|
||||
if integrated.iter().any(|k| n.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
|
||||
|
|
@ -123,6 +126,22 @@ pub fn looks_integrated(name: &str) -> bool {
|
|||
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
|
||||
}
|
||||
|
||||
/// An Intel name carrying "Arc" (lower-cased): the cards ("Arc(TM) A770", "Arc(TM) B580", "Arc(TM) Pro A60") are
|
||||
/// discrete; the Arc-branded processor graphics are integrated: the bare "Intel(R) Arc(TM) Graphics" of Meteor Lake
|
||||
/// and Arrow Lake, the "Arc(TM) 130V / 140V" of Lunar Lake and the "Arc(TM) 1xxT" of Panther Lake (approximate names
|
||||
/// from Intel's product pages, 7 October 2026; the B580 is the first Intel card in hand, docs/plans/intel-arc.md).
|
||||
fn arc_is_integrated(lower: &str) -> bool {
|
||||
let after = lower.split("arc").nth(1).unwrap_or("").trim_start_matches("(tm)").trim_start_matches("(r)").trim();
|
||||
let word = after.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or("");
|
||||
// "graphics" or nothing after "Arc": the processor graphics; "130v", "140t": digits first, the processor graphics
|
||||
if word.is_empty() || word == "graphics" || word.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
// "pro", "a770", "b580": a card. Anything else unknown is read as a card (on by default; a wrong call costs an
|
||||
// iGPU 8 identities at 1 to 2 MH/s, a card called integrated would sit off with no reason the owner can see)
|
||||
false
|
||||
}
|
||||
|
||||
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adapter {
|
||||
|
|
@ -137,6 +156,11 @@ pub struct Adapter {
|
|||
pub pnp_id: String,
|
||||
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
|
||||
pub bus: String,
|
||||
/// the DriverVersion property ("32.0.101.9034"); empty when none is bound (driver-check, 7 October 2026)
|
||||
pub driver: String,
|
||||
/// the device sits behind a USB4 or Thunderbolt router in its parent chain (an eGPU enclosure; PC 2's RTX 5060 Ti in a
|
||||
/// Razer Core X V2, 7 October 2026): the kind reads "external" and the Cards page says eGPU
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
|
|
@ -174,6 +198,9 @@ pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
|
|||
if a.ram_mb > 0 && a.ram_mb < 1024 {
|
||||
return "integrated";
|
||||
}
|
||||
if a.external {
|
||||
return "external";
|
||||
}
|
||||
}
|
||||
"discrete"
|
||||
}
|
||||
|
|
@ -186,6 +213,10 @@ pub fn vendor_of(name: &str) -> &'static str {
|
|||
"amd"
|
||||
} else if n.contains("apple") {
|
||||
"apple"
|
||||
} else if n.contains("intel") {
|
||||
// Arc cards and Intel processor graphics alike (the kind tells them apart); the first Intel card is the B580
|
||||
// on PC 1, 7 October 2026 (docs/plans/intel-arc.md)
|
||||
"intel"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
|
|
@ -208,18 +239,25 @@ pub fn parse_adapters(json: &str) -> Vec<Adapter> {
|
|||
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
|
||||
_ => String::new(),
|
||||
};
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
|
||||
let external = r.get("External").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), driver: s(r, "DriverVersion"), bus, external }
|
||||
})
|
||||
.filter(|a| !a.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The PowerShell behind adapters(): one object per adapter with the PCI bus and address and whether a USB4 or Thunderbolt
|
||||
/// router sits in the device's parent chain (External), which is how an eGPU enclosure shows (PC 2's Razer Core X V2 reads
|
||||
/// "USB4 Router (2.0), Razer - Core X V2", instance USB4\VID_8087&PID_5786...).
|
||||
pub const ADAPTERS_SCRIPT: &str = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; $ext = $false; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; try { $cur = $id; for ($i = 0; $i -lt 6 -and $cur; $i++) { $par = (Get-PnpDeviceProperty -InstanceId $cur -KeyName 'DEVPKEY_Device_Parent' -ErrorAction Stop).Data; if (-not $par) { break }; if (\"$par\" -match '^USB4\\\\|THUNDERBOLT|TBT') { $ext = $true; break }; $cur = $par } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; DriverVersion = $_.DriverVersion; PNPDeviceID = $id; BusNumber = $bus; Address = $addr; External = $ext } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
|
||||
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
|
||||
#[cfg(windows)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
|
||||
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
|
||||
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
// External: the parent chain (DEVPKEY_Device_Parent, up to 6 hops) holds a USB4 router or a Thunderbolt device
|
||||
let script = ADAPTERS_SCRIPT;
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
|
||||
let start = out.find(|c| c == '[' || c == '{')?;
|
||||
Some(parse_adapters(&out[start..]))
|
||||
|
|
@ -440,6 +478,9 @@ impl ClDevice {
|
|||
"nvidia"
|
||||
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
|
||||
"amd"
|
||||
} else if self.vendor.contains("Intel") || self.name.contains("Intel") {
|
||||
// "Intel(R) Corporation" on the "Intel(R) OpenCL Graphics" platform (the Arc driver's runtime)
|
||||
"intel"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
|
|
@ -601,6 +642,8 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
c.bus = bus;
|
||||
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = mem_mb;
|
||||
// driver-check: nvidia-smi's driver_version ("581.57"); the adapter row's DriverVersion is the fallback
|
||||
c.driver_os = parts.get(4).map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && !v.contains("N/A")).or_else(|| adapter.map(|i| adapters[i].driver.clone())).unwrap_or_default();
|
||||
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
|
||||
if !inp.cuda_worker {
|
||||
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
||||
|
|
@ -639,6 +682,8 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
|
||||
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
|
||||
// driver-check: Windows' DriverVersion for the card (AMD "32.0.32015.2008", Intel "32.0.101.9034")
|
||||
c.driver_os = adapter.map(|i| adapters[i].driver.clone()).unwrap_or_default();
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
|
|
@ -654,6 +699,7 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
|
||||
c.driver_os = a.driver.clone();
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
used.push(i);
|
||||
|
|
@ -670,6 +716,7 @@ pub fn assemble(inp: Inputs) -> Detection {
|
|||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = classify_kind(&a.name, Some(a)).into();
|
||||
c.driver_os = a.driver.clone();
|
||||
mark_unusable(&mut c, &problem);
|
||||
d.cards.push(c);
|
||||
}
|
||||
|
|
@ -683,7 +730,7 @@ pub fn detect(bins: &Bins) -> Detection {
|
|||
// processor for the integrated call, the PCI address and the names for the rows
|
||||
let adapters = adapters();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id,driver_version", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
|
||||
|
|
@ -788,6 +835,15 @@ mod tests {
|
|||
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
|
||||
// PC 2, 7 October 2026: the RTX 5060 Ti behind the Razer Core X V2's USB4 router is an eGPU, not a discrete card
|
||||
let egpu = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5060 Ti","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04&SUBSYS_8A111043&REV_A1\\31C898B6A12DB04800","BusNumber":11,"Address":0,"External":true}]"#);
|
||||
assert_eq!(egpu.len(), 1);
|
||||
assert!(egpu[0].external && egpu[0].bus == "0b:00.0");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&egpu[0])), "external");
|
||||
let inside = parse_adapters(r#"[{"Name":"NVIDIA GeForce RTX 5060 Ti","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"x","PNPDeviceID":"PCI\\VEN_10DE&DEV_2D04\\1","BusNumber":1,"Address":0}]"#);
|
||||
assert!(!inside[0].external, "no External field reads as inside the case");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5060 Ti", Some(&inside[0])), "discrete");
|
||||
assert!(ADAPTERS_SCRIPT.contains("DEVPKEY_Device_Parent") && ADAPTERS_SCRIPT.contains("USB4") && ADAPTERS_SCRIPT.contains("External = $ext"), "the script walks the parent chain");
|
||||
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
|
||||
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
|
||||
|
|
@ -809,6 +865,8 @@ mod tests {
|
|||
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
|
||||
assert!(!looks_integrated("Apple M5 Max"));
|
||||
assert_eq!(vendor_of("Apple M5 Max"), "apple");
|
||||
assert_eq!(vendor_of("Intel(R) Arc(TM) B580 Graphics"), "intel");
|
||||
assert_eq!(vendor_of("Intel(R) UHD Graphics 770"), "intel");
|
||||
assert_eq!(vendor_of("gfx1036"), "amd");
|
||||
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
|
||||
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
|
||||
|
|
@ -951,6 +1009,38 @@ mod tests {
|
|||
assert!(diff.removed.is_empty());
|
||||
}
|
||||
|
||||
/// The first Intel card (the B580 on PC 1, 7 October 2026, docs/plans/intel-arc.md): the Arc driver's OpenCL
|
||||
/// platform lists it as "Intel(R) Arc(TM) B580 Graphics" under vendor "Intel(R) Corporation" (the line shape is
|
||||
/// the worker's; the device name and vendor string are Intel's documented forms, approximate until the PC 1 job
|
||||
/// prints them). It becomes a discrete 12 GB card, vendor intel, worker OpenCL, on with 8 identities; the
|
||||
/// Arc-branded processor graphics stay integrated; the NVIDIA cards beside it are untouched.
|
||||
#[test]
|
||||
fn an_intel_arc_card_on_the_intel_platform_is_a_discrete_intel_card() {
|
||||
let list = String::from(PC1_LIST)
|
||||
+ " [4] Intel(R) Arc(TM) B580 Graphics | Intel(R) OpenCL Graphics (OpenCL 3.0 )\n GPU, vendor Intel(R) Corporation, driver 32.0.101.9034, OpenCL C 3.0, 160 compute units, 2850 MHz, pci 05:00.0\n global 12208 MiB, max alloc 4095 MiB, local 64 KiB, max work-group 1024, sub-group extension: cl_intel_subgroups\n";
|
||||
let (devs, _) = parse_opencl_list(&list);
|
||||
let arc = devs.iter().find(|d| d.name.contains("B580")).expect("the Arc line parses");
|
||||
assert_eq!(arc.vendor_word(), "intel");
|
||||
assert_eq!(arc.mem_mb, 12208);
|
||||
assert_eq!(arc.bus, "05:00.0");
|
||||
let d = assemble(pc1_inputs(&list));
|
||||
let c = d.cards.iter().find(|c| c.vendor == "intel").expect("an intel card");
|
||||
assert_eq!((c.name.as_str(), c.kind.as_str(), c.worker.as_str(), c.device.as_str(), c.enabled, c.identities), ("Intel(R) Arc(TM) B580 Graphics", "discrete", "OpenCL", "4", true, 8));
|
||||
assert_eq!(c.key, "intel:Intel(R) Arc(TM) B580 Graphics");
|
||||
assert_eq!(c.vram_mb, 12208);
|
||||
assert!(!c.sweep_supported, "no cap through OpenCL");
|
||||
assert!(c.sweep_note.contains("Intel Arc"), "{}", c.sweep_note);
|
||||
assert!(d.listed(c), "the OpenCL list answered, so a vanished Arc can be called removed");
|
||||
// the Arc names: cards discrete, processor graphics integrated
|
||||
for card in ["Intel(R) Arc(TM) B580 Graphics", "Intel(R) Arc(TM) A770 Graphics", "Intel(R) Arc(TM) A380 Graphics", "Intel(R) Arc(TM) Pro A60 Graphics", "Intel(R) Arc(TM) B570 Graphics"] {
|
||||
assert_eq!(classify_kind(card, None), "discrete", "{card}");
|
||||
}
|
||||
for igpu in ["Intel(R) Arc(TM) Graphics", "Intel(R) Arc(TM) 140V GPU (16GB)", "Intel(R) Arc(TM) 130V GPU", "Intel(R) Arc(TM) 140T GPU"] {
|
||||
assert_eq!(classify_kind(igpu, None), "integrated", "{igpu}");
|
||||
}
|
||||
assert_eq!(classify_kind("Intel(R) UHD Graphics 770", None), "integrated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_number_identical_cards() {
|
||||
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];
|
||||
|
|
|
|||
213
app/igneum-app/src/drivers.rs
Normal file
213
app/igneum-app/src/drivers.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! Driver check (branch driver-check, 7 October 2026; the project lead: "can we package the drivers with the miner? for all
|
||||
//! cards? and the system knows which to install if not present"). The answer given: not bundled (size, vendor
|
||||
//! licences, staleness), but detected and installed on one click.
|
||||
//!
|
||||
//! The manifest carries a per-vendor table (`drivers`, signed with the rest): the version the worker needs at least,
|
||||
//! the version on offer, the vendor's own download URL, size, sha256 and the page the hash was read from, the
|
||||
//! installer's silent arguments and the exit codes that mean "restart required". The app never ships a driver: the
|
||||
//! table rides the manifest (ota.rs writes `<app data>/drivers.json`), so a new vendor release is a manifest publish.
|
||||
//!
|
||||
//! At every detection each card's driver version (nvidia-smi's for NVIDIA, Windows' DriverVersion for AMD and Intel)
|
||||
//! is compared with the table; a missing or old driver puts an offer on the card's row ("Install the NVIDIA driver
|
||||
//! 581.57, 650 MB"). The click downloads from the vendor's server (curl with resume), checks size, sha256 and the
|
||||
//! Authenticode signature (the signer must be the vendor), then runs the installer through ONE elevated prompt
|
||||
//! (platform::elevated_command, the PC 1 driver job's fetch, verify and -s shape), reports "restart required" with a
|
||||
//! Restart now button and never restarts by itself. Nothing else pauses: the miners keep mining through it.
|
||||
//! macOS: no driver step (the row says so). Linux and HiveOS: a line naming the package, no installer.
|
||||
//!
|
||||
//! Dry run (`IGNEUM_DRIVER_DRY_RUN=1`, or `dry_run: true` in the table): the download and every check run, the
|
||||
//! installer does not: the install step reports what it would have run and exit 0. The tests feed the table a
|
||||
//! 127.0.0.1 URL served by a std TcpListener (the mocked vendor response).
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub use crate::drivertable::{exit_meaning, offer_for, platform_word, DriverState, Offer, Table, VendorEntry};
|
||||
#[cfg(windows)]
|
||||
use crate::drivertable::authenticode_verdict;
|
||||
|
||||
/// The install thread's reports.
|
||||
pub enum Event {
|
||||
Progress(f64, String),
|
||||
/// the installer ran: its exit code and whether that means a restart
|
||||
Installed(Result<(i64, bool, String), String>),
|
||||
}
|
||||
|
||||
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
let mut c = Command::new(crate::platform::tool("curl"));
|
||||
c.args(args);
|
||||
let out = crate::detect::run_timeout(&mut c, None, limit).ok_or("curl is not available")?;
|
||||
let t = out.trim().to_string();
|
||||
if t.is_empty() { Ok(()) } else { Err(t.lines().last().unwrap_or("curl failed").to_string()) }
|
||||
}
|
||||
|
||||
/// Downloads the vendor's file with resume into `dir`, checks size and sha256, renames `.part` to the final name.
|
||||
pub fn download(e: &VendorEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let name = e.url.rsplit('/').next().filter(|n| !n.is_empty() && !n.contains("..")).unwrap_or("driver.exe").to_string();
|
||||
let final_path = dir.join(&name);
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
std::fs::create_dir_all(dir).map_err(|x| format!("cannot make {}: {x}", dir.display()))?;
|
||||
if final_path.is_file() && std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0) == e.size && crate::manifest::sha256_file(&final_path).map(|s| s == e.sha256).unwrap_or(false) {
|
||||
return Ok(final_path);
|
||||
}
|
||||
let _ = std::fs::remove_file(&final_path);
|
||||
let have = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
|
||||
if have > e.size {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
}
|
||||
if have != e.size {
|
||||
// drivers.amd.com answers 403 without an amd.com Referer (igneum-build-2, 7 October 2026): the row names one
|
||||
let mut args = vec!["-fsSL", "--retry", "3", "--retry-delay", "5", "-C", "-", "--max-time", "7200", "-A", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) IgneumMiner"];
|
||||
if !e.referer.is_empty() {
|
||||
args.push("-e");
|
||||
args.push(&e.referer);
|
||||
}
|
||||
let part_s = part.display().to_string();
|
||||
args.extend(["-o", &part_s, &e.url]);
|
||||
curl(&args, Duration::from_secs(7260))?;
|
||||
}
|
||||
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
|
||||
if got != e.size {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("size mismatch: got {got} bytes, the table says {}", e.size));
|
||||
}
|
||||
let sum = crate::manifest::sha256_file(&part).map_err(|x| x.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
return Err(format!("sha256 mismatch: the file is not the one the table names (page {})", e.hash_source));
|
||||
}
|
||||
std::fs::rename(&part, &final_path).map_err(|x| x.to_string())?;
|
||||
Ok(final_path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn authenticode(path: &Path, signer: &str) -> Result<String, String> {
|
||||
let ps = format!("$s = Get-AuthenticodeSignature -LiteralPath '{}'; Write-Output ('status=' + $s.Status); Write-Output ('subject=' + $s.SignerCertificate.Subject)", path.display().to_string().replace('\'', "''"));
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]), None, Duration::from_secs(60)).ok_or("PowerShell did not answer the signature check")?;
|
||||
authenticode_verdict(&out, signer)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn authenticode(_path: &Path, _signer: &str) -> Result<String, String> {
|
||||
Err("the installer runs on Windows only".into())
|
||||
}
|
||||
|
||||
/// The click: download, verify, run elevated (one prompt), report. Runs on its own thread; the miners keep mining.
|
||||
pub fn start_install(shared: &Arc<Shared>, e: VendorEntry, dir: PathBuf, dry_run: bool) {
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.05, format!("downloading {} ({} MB) from {}", e.version, (e.size + 512 * 1024) / (1024 * 1024), host_of(&e.url)))));
|
||||
let file = match download(&e, &dir) {
|
||||
Ok(f) => f,
|
||||
Err(x) => {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Err(format!("download: {x}")))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.6, "size and sha256 match the table; checking the signature".into())));
|
||||
if !dry_run {
|
||||
if let Err(x) = authenticode(&file, &e.signer) {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Err(x))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
shared2.send(Cmd::Driver(Event::Progress(0.7, format!("{}installing {} {}: Windows asks for permission once", if dry_run { "dry run: " } else { "" }, e.vendor.to_ascii_uppercase(), e.version))));
|
||||
if dry_run {
|
||||
shared2.send(Cmd::Driver(Event::Installed(Ok((0, false, format!("dry run: would run {} {}", file.display(), e.args.join(" ")))))));
|
||||
return;
|
||||
}
|
||||
let args = e.args.join(" ");
|
||||
let mut c = crate::platform::elevated_command(&file.display().to_string(), &args);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800));
|
||||
let code = out.as_deref().and_then(|t| t.lines().rev().find_map(|l| l.trim().parse::<i64>().ok())).unwrap_or(-1);
|
||||
// elevated_ps_line prints nothing on success and exits with the installer's code; run_timeout only gives
|
||||
// stdout, so the exit code is read from the wrapper's own echo below
|
||||
let code = exit_code_of(&file, &args, code);
|
||||
let (reboot, text) = exit_meaning(code, &e);
|
||||
shared2.send(Cmd::Driver(Event::Installed(Ok((code, reboot, text)))));
|
||||
});
|
||||
}
|
||||
|
||||
/// The elevated wrapper's exit code: `elevated_ps_line` exits with the installer's code, which run_timeout does not
|
||||
/// return; so the installer is run through a second form that echoes the code on its last line.
|
||||
#[cfg(windows)]
|
||||
fn exit_code_of(file: &Path, args: &str, _seen: i64) -> i64 {
|
||||
let line = format!("{}; Write-Output ('exit=' + $LASTEXITCODE)", crate::platform::elevated_ps_line(&file.display().to_string(), args).trim_end_matches("exit $p.ExitCode").to_string() + "$global:LASTEXITCODE = $p.ExitCode");
|
||||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &line]);
|
||||
crate::platform::quiet(&mut c);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(1800)).unwrap_or_default();
|
||||
out.lines().rev().find_map(|l| l.trim().strip_prefix("exit=").and_then(|v| v.parse::<i64>().ok())).unwrap_or(-1)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn exit_code_of(_file: &Path, _args: &str, seen: i64) -> i64 {
|
||||
seen
|
||||
}
|
||||
|
||||
fn host_of(url: &str) -> String {
|
||||
url.split("//").nth(1).and_then(|r| r.split('/').next()).unwrap_or("the vendor").to_string()
|
||||
}
|
||||
|
||||
/// "Restart now": a plain restart in 20 s (no elevation needed for the signed-in user); never called by the app itself.
|
||||
pub fn restart_now() -> Result<(), String> {
|
||||
if !cfg!(windows) {
|
||||
return Err("restart from the app is for Windows only".into());
|
||||
}
|
||||
let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let mut c = Command::new(format!("{root}\\System32\\shutdown.exe"));
|
||||
c.args(["/r", "/t", "20", "/c", "Igneum Miner: restarting to finish the driver install"]);
|
||||
crate::platform::quiet(&mut c);
|
||||
match crate::detect::run_timeout(&mut c, None, Duration::from_secs(20)) {
|
||||
Some(t) if t.trim().is_empty() => Ok(()),
|
||||
Some(t) => Err(t.trim().to_string()),
|
||||
None => Err("shutdown.exe did not answer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod download_tests {
|
||||
use super::*;
|
||||
|
||||
/// The mocked vendor response: a std TcpListener on 127.0.0.1 serves the file; the right sha256 passes, a wrong
|
||||
/// one is refused and the part file is gone; a second call with the file in place downloads nothing.
|
||||
#[test]
|
||||
fn download_verifies_against_a_mocked_vendor_server() {
|
||||
use std::io::{Read, Write};
|
||||
let body: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let served = body.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().take(3) {
|
||||
let mut s = stream.unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let _ = s.read(&mut buf);
|
||||
let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n", served.len());
|
||||
let _ = s.write_all(head.as_bytes());
|
||||
let _ = s.write_all(&served);
|
||||
}
|
||||
});
|
||||
let dir = std::env::temp_dir().join(format!("igneum-driver-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
use sha2::Digest;
|
||||
let sum = format!("{:x}", sha2::Sha256::digest(&body));
|
||||
let mut e = VendorEntry { vendor: "intel".into(), version: "1.0".into(), min_version: "1.0".into(), url: format!("http://127.0.0.1:{port}/gfx_test.exe"), size: body.len() as u64, sha256: sum.clone(), args: vec!["-s".into()], signer: "Intel".into(), ..Default::default() };
|
||||
let f = download(&e, &dir).expect("the right sha256 passes");
|
||||
assert_eq!(f.file_name().unwrap(), "gfx_test.exe");
|
||||
assert_eq!(std::fs::read(&f).unwrap(), body);
|
||||
assert!(download(&e, &dir).is_ok(), "the file in place is kept without a second download");
|
||||
let _ = std::fs::remove_file(&f);
|
||||
e.sha256 = "0".repeat(64);
|
||||
let err = download(&e, &dir).unwrap_err();
|
||||
assert!(err.contains("sha256 mismatch"), "{err}");
|
||||
assert!(!dir.join("gfx_test.exe.part").exists() && !dir.join("gfx_test.exe").exists());
|
||||
e.sha256 = sum;
|
||||
e.size = 7;
|
||||
assert!(download(&e, &dir).unwrap_err().contains("size mismatch"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
395
app/igneum-app/src/drivertable.rs
Normal file
395
app/igneum-app/src/drivertable.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
//! The driver table and the offer decision (branch driver-check, 7 October 2026): the pure half of src/drivers.rs,
|
||||
//! with no dependency on the engine, so the signer binary (src/bin/ota-sign.rs) validates a manifest's `drivers`
|
||||
//! object the way the app does. The install thread, the download and the Authenticode call live in drivers.rs.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// One vendor's row of the table.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct VendorEntry {
|
||||
pub vendor: String,
|
||||
/// the lowest version the worker runs on (NVIDIA: nvidia-smi's "570.00"; AMD and Intel: Windows' DriverVersion "32.0.101.9034")
|
||||
pub min_version: String,
|
||||
/// what the click installs
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
/// where the sha256 was read (the vendor's page), for the record on the row
|
||||
pub hash_source: String,
|
||||
/// a Referer the vendor's server requires (drivers.amd.com answers 403 without an amd.com one); empty = none
|
||||
pub referer: String,
|
||||
/// the installer's silent arguments
|
||||
pub args: Vec<String>,
|
||||
/// exit codes that mean "installed, restart required"
|
||||
pub reboot_codes: Vec<i64>,
|
||||
/// a word the Authenticode subject must carry ("NVIDIA", "Advanced Micro Devices", "Intel")
|
||||
pub signer: String,
|
||||
/// Linux and HiveOS: the package to name, no installer
|
||||
pub linux_package: String,
|
||||
pub hive_package: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Table {
|
||||
pub updated: String,
|
||||
pub dry_run: bool,
|
||||
pub vendors: Vec<VendorEntry>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// The manifest's `drivers` object. Every entry is checked the way the platform entries are: https (or 127.0.0.1
|
||||
/// for a test), 64-hex sha256, a size, a version on both sides, at least one silent argument.
|
||||
pub fn parse(v: &serde_json::Value) -> Result<Table, String> {
|
||||
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let obj = v.get("vendors").and_then(|x| x.as_object()).ok_or("drivers: no vendors object")?;
|
||||
let mut vendors = Vec::new();
|
||||
for (name, e) in obj {
|
||||
if e.is_null() {
|
||||
continue;
|
||||
}
|
||||
let vendor = name.to_ascii_lowercase();
|
||||
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
|
||||
return Err(format!("drivers: vendor '{name}' is not nvidia, amd or intel"));
|
||||
}
|
||||
let entry = VendorEntry {
|
||||
vendor: vendor.clone(),
|
||||
min_version: s(e, "min_version"),
|
||||
version: s(e, "version"),
|
||||
url: s(e, "url"),
|
||||
size: e.get("size").and_then(|x| x.as_u64()).unwrap_or(0),
|
||||
sha256: s(e, "sha256").to_ascii_lowercase(),
|
||||
hash_source: s(e, "hash_source"),
|
||||
referer: s(e, "referer"),
|
||||
args: e.get("args").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).map(|x| x.to_string()).collect()).unwrap_or_default(),
|
||||
reboot_codes: e.get("reboot_codes").and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_i64()).collect()).unwrap_or_default(),
|
||||
signer: s(e, "signer"),
|
||||
linux_package: s(e, "linux_package"),
|
||||
hive_package: s(e, "hive_package"),
|
||||
};
|
||||
if parse_dotted(&entry.min_version).is_none() || parse_dotted(&entry.version).is_none() {
|
||||
return Err(format!("drivers.{name}: min_version and version must be dotted numbers"));
|
||||
}
|
||||
if !entry.url.starts_with("https://") && !entry.url.starts_with("http://127.0.0.1:") {
|
||||
return Err(format!("drivers.{name}: the url is not https"));
|
||||
}
|
||||
if entry.sha256.len() != 64 || !entry.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("drivers.{name}: sha256 is not 64 hex characters"));
|
||||
}
|
||||
if entry.size == 0 {
|
||||
return Err(format!("drivers.{name}: size is missing"));
|
||||
}
|
||||
if entry.args.is_empty() || entry.args.iter().any(|a| a.contains(['"', '\'', '&', '|', ';', '`'])) {
|
||||
return Err(format!("drivers.{name}: args must be plain switches"));
|
||||
}
|
||||
if !entry.referer.is_empty() && !entry.referer.starts_with("https://") {
|
||||
return Err(format!("drivers.{name}: referer must be https"));
|
||||
}
|
||||
if entry.signer.is_empty() {
|
||||
return Err(format!("drivers.{name}: signer is missing (the Authenticode subject word)"));
|
||||
}
|
||||
vendors.push(entry);
|
||||
}
|
||||
if vendors.is_empty() {
|
||||
return Err("drivers: the vendors object is empty".into());
|
||||
}
|
||||
vendors.sort_by(|a, b| a.vendor.cmp(&b.vendor));
|
||||
Ok(Table { updated: s(v, "updated"), dry_run: v.get("dry_run").and_then(|x| x.as_bool()).unwrap_or(false), vendors })
|
||||
}
|
||||
|
||||
pub fn entry(&self, vendor: &str) -> Option<&VendorEntry> {
|
||||
self.vendors.iter().find(|e| e.vendor == vendor)
|
||||
}
|
||||
}
|
||||
|
||||
/// "32.0.101.9034" or "581.57" as numbers; None for anything else (an empty string, "3683.0 (PAL,LC)").
|
||||
pub fn parse_dotted(s: &str) -> Option<Vec<u64>> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in t.split('.') {
|
||||
out.push(p.trim().parse::<u64>().ok()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `a` is at least `b`, compared part by part (a missing trailing part reads 0).
|
||||
pub fn at_least(a: &[u64], b: &[u64]) -> bool {
|
||||
let n = a.len().max(b.len());
|
||||
for i in 0..n {
|
||||
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||
if x != y {
|
||||
return x > y;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What the card's row shows about its driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct Offer {
|
||||
pub vendor: String,
|
||||
/// "missing" | "old" | "fine" | "none" (no row for this vendor in the table)
|
||||
pub status: String,
|
||||
pub installed: String,
|
||||
pub wanted: String,
|
||||
pub min_version: String,
|
||||
pub size: u64,
|
||||
pub url: String,
|
||||
pub hash_source: String,
|
||||
/// the row's sentence
|
||||
pub text: String,
|
||||
/// true when the click can install here (Windows with an entry); false with a note on macOS, Linux and HiveOS
|
||||
pub installable: bool,
|
||||
}
|
||||
|
||||
/// The platform word for the notes: "windows" | "macos" | "linux" | "hive".
|
||||
pub fn platform_word() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if Path::new("/hive").is_dir() || Path::new("/hive-config").is_dir() {
|
||||
"hive"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
/// The offer for a card: its vendor, the version its driver reports (empty = none found) and the table.
|
||||
pub fn offer_for(vendor: &str, installed: &str, table: Option<&Table>, platform: &str) -> Option<Offer> {
|
||||
if vendor == "apple" {
|
||||
return Some(Offer { vendor: "apple".into(), status: "none".into(), installed: installed.into(), text: "Apple silicon: no driver step, macOS carries it.".into(), ..Default::default() });
|
||||
}
|
||||
let e = table.and_then(|t| t.entry(vendor))?;
|
||||
let word = match vendor {
|
||||
"nvidia" => "NVIDIA",
|
||||
"amd" => "AMD",
|
||||
"intel" => "Intel Arc",
|
||||
_ => vendor,
|
||||
};
|
||||
let mb = (e.size + 512 * 1024) / (1024 * 1024);
|
||||
let size_text = if mb >= 1024 { format!("{:.1} GB", mb as f64 / 1024.0) } else { format!("{mb} MB") };
|
||||
let have = parse_dotted(installed);
|
||||
let need = parse_dotted(&e.min_version).unwrap_or_default();
|
||||
let status = match &have {
|
||||
None => "missing",
|
||||
Some(h) if at_least(h, &need) => "fine",
|
||||
Some(_) => "old",
|
||||
};
|
||||
let base = Offer { vendor: vendor.into(), status: status.into(), installed: installed.into(), wanted: e.version.clone(), min_version: e.min_version.clone(), size: e.size, url: e.url.clone(), hash_source: e.hash_source.clone(), text: String::new(), installable: false };
|
||||
let text = match (platform, status) {
|
||||
("macos", _) => "macOS: no driver step.".to_string(),
|
||||
("hive", "fine") | ("linux", "fine") => format!("{word} driver {installed}: fine."),
|
||||
("hive", _) => format!("HiveOS: {} the driver with {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.hive_package.is_empty() { "hive's driver tool" } else { &e.hive_package }, e.min_version),
|
||||
("linux", _) => format!("Linux: {} the package {} (the worker needs {} or newer).", if status == "missing" { "install" } else { "update" }, if e.linux_package.is_empty() { "the vendor's driver" } else { &e.linux_package }, e.min_version),
|
||||
(_, "fine") => format!("{word} driver {installed}: fine."),
|
||||
(_, "missing") => format!("Install the {word} driver {} ({size_text}). No driver was found, so this card cannot mine yet.", e.version),
|
||||
_ => format!("Install the {word} driver {} ({size_text}). Driver {installed} is older than the {} the worker needs.", e.version, e.min_version),
|
||||
};
|
||||
Some(Offer { text, installable: platform == "windows" && status != "fine", ..base })
|
||||
}
|
||||
|
||||
/// The installer's exit code read: (restart required, the sentence).
|
||||
pub fn exit_meaning(code: i64, e: &VendorEntry) -> (bool, String) {
|
||||
if code == 0 {
|
||||
return (false, format!("{} driver {} installed.", e.vendor.to_ascii_uppercase(), e.version));
|
||||
}
|
||||
if e.reboot_codes.contains(&code) {
|
||||
return (true, format!("{} driver {} installed. Restart Windows to finish.", e.vendor.to_ascii_uppercase(), e.version));
|
||||
}
|
||||
(false, format!("the {} installer exited with code {code}.", e.vendor.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
/// The install's progress, published as `state.drivers`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct DriverState {
|
||||
/// idle | downloading | verifying | installing | done | reboot | error
|
||||
pub status: String,
|
||||
pub vendor: String,
|
||||
pub version: String,
|
||||
pub progress: f64,
|
||||
pub message: String,
|
||||
pub error: String,
|
||||
pub reboot_required: bool,
|
||||
pub dry_run: bool,
|
||||
pub started_at: f64,
|
||||
pub finished_at: f64,
|
||||
/// the table's updated stamp, for the Settings page ("drivers table of 7 October")
|
||||
pub table_updated: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
/// The Authenticode verdict from `Get-AuthenticodeSignature`'s two lines ("Valid" and the subject): Ok(subject) when
|
||||
/// the status is Valid and the subject carries the vendor's word.
|
||||
pub fn authenticode_verdict(text: &str, signer: &str) -> Result<String, String> {
|
||||
let mut status = String::new();
|
||||
let mut subject = String::new();
|
||||
for l in text.lines() {
|
||||
if let Some(v) = l.strip_prefix("status=") {
|
||||
status = v.trim().to_string();
|
||||
} else if let Some(v) = l.strip_prefix("subject=") {
|
||||
subject = v.trim().to_string();
|
||||
}
|
||||
}
|
||||
if status != "Valid" {
|
||||
return Err(format!("the file's signature is {}: not installed", if status.is_empty() { "unreadable".to_string() } else { status }));
|
||||
}
|
||||
if !subject.to_ascii_lowercase().contains(&signer.to_ascii_lowercase()) {
|
||||
return Err(format!("the file is signed by \"{subject}\", not {signer}: not installed"));
|
||||
}
|
||||
Ok(subject)
|
||||
}
|
||||
|
||||
/// The cards whose worker stops before a vendor's driver installer starts: every enabled card of that vendor. PC 2,
|
||||
/// 7 October 2026, 16:26Z: the Intel installer took the kernel down (bugcheck 0x3B) with the app's worker mining on the
|
||||
/// Arc B580 in a Razer Core X V2; at 19:14 BST the same card read kind=discrete on 0.3.21 (no USB4 or Thunderbolt
|
||||
/// router in its parent chain on that board), so a hold keyed on the external kind alone would have missed the card
|
||||
/// that crashed the box. The rule since 0.3.22 (the shipper's word, 19:1x BST): the vendor's cards all stop, the other
|
||||
/// vendors' cards keep mining, a card already off has nothing to stop. Each item is (key, vendor, kind, enabled).
|
||||
pub fn install_hold_keys<'a>(cards: impl IntoIterator<Item = (&'a str, &'a str, &'a str, bool)>, vendor: &str) -> Vec<String> {
|
||||
cards.into_iter().filter(|(_, v, _, enabled)| *v == vendor && *enabled).map(|(key, _, _, _)| key.to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn the_shipped_table_accepts_the_inbox_6733_driver_for_the_arc_b580_until_an_unattended_install_exists() {
|
||||
// the project lead's rule, 7 October 2026 evening: no PC job needs a click or a UAC prompt. The one-click install asks for
|
||||
// one, so the table does not demand 9034 of an Arc on Windows' inbox 6733 (the worker mines at 11.0 MH/s on it
|
||||
// with the Intel rotate rewrite); 9034 stays the version on offer for a card with no driver at all.
|
||||
let v: serde_json::Value = serde_json::from_str(include_str!("../../../packaging/ota/drivers.json")).expect("the shipped table is JSON");
|
||||
let t = Table::parse(&v).expect("the shipped table parses");
|
||||
let intel = t.entry("intel").expect("an Intel row");
|
||||
assert_eq!(intel.min_version, "32.0.101.6733", "6733 is acceptable for the B580 until the Power Helper task installs drivers unattended");
|
||||
assert_eq!(intel.version, "32.0.101.9034");
|
||||
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable), ("fine", false), "{}", o.text);
|
||||
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
|
||||
assert!(o.installable && o.text.starts_with("Install the Intel Arc driver 32.0.101.9034"), "{}", o.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driver_install_stops_every_card_of_its_vendor_first_and_leaves_the_other_vendors_mining() {
|
||||
// PC 2, 7 October 2026: the Arc B580 in the Razer Core X V2 was mining when the Intel installer's display
|
||||
// reset took the machine down. The rule: the vendor's external cards stop, nothing else does.
|
||||
let cards = [
|
||||
("nvidia:0:NVIDIA GeForce RTX 5090", "nvidia", "discrete", true),
|
||||
("nvidia:1:NVIDIA GeForce RTX 5060 Ti", "nvidia", "external", true),
|
||||
("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", true),
|
||||
("intel::Intel UHD 770", "other", "integrated", false),
|
||||
];
|
||||
assert_eq!(install_hold_keys(cards, "intel"), vec!["intel:Intel(R) Arc(TM) B580 Graphics".to_string()], "the Arc in the enclosure stops; the NVIDIA cards and the iGPU keep mining");
|
||||
assert_eq!(install_hold_keys(cards, "nvidia"), vec!["nvidia:0:NVIDIA GeForce RTX 5090".to_string(), "nvidia:1:NVIDIA GeForce RTX 5060 Ti".to_string()], "every NVIDIA card stops for the NVIDIA install, inside the case or not");
|
||||
assert!(install_hold_keys(cards, "amd").is_empty(), "no AMD card: nothing stops");
|
||||
// a card already off has nothing to stop; a discrete card of the vendor IS held (PC 2, 19:14 BST: the Arc in the
|
||||
// Razer Core X V2 read kind=discrete on 0.3.21, so the kind alone would have missed the card that crashed the box)
|
||||
let off = [("intel:Intel(R) Arc(TM) B580 Graphics", "intel", "external", false), ("intel:Intel(R) Arc(TM) A770 Graphics", "intel", "discrete", true)];
|
||||
assert_eq!(install_hold_keys(off, "intel"), vec!["intel:Intel(R) Arc(TM) A770 Graphics".to_string()]);
|
||||
// the iGPU carries vendor "other" on PC 2 (an AMD Radeon(TM) Graphics as amd:gfx1036 on another read): the install's
|
||||
// vendor word decides, and a card off stays untouched
|
||||
let igpu = [("amd:gfx1036", "amd", "integrated", false), ("amd:gfx1201", "amd", "discrete", true)];
|
||||
assert_eq!(install_hold_keys(igpu, "amd"), vec!["amd:gfx1201".to_string()]);
|
||||
}
|
||||
|
||||
|
||||
const TABLE: &str = r#"{"updated":"2026-10-07","vendors":{
|
||||
"nvidia":{"min_version":"570.00","version":"617.42","url":"https://us.download.nvidia.com/Windows/617.42/617.42-desktop-win10-win11-64bit-international-dch-whql.exe","size":990853168,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash_source":"https://www.nvidia.com/en-us/drivers/details/","args":["-s","-noreboot"],"reboot_codes":[1],"signer":"NVIDIA","linux_package":"nvidia-driver-570","hive_package":"nvidia-driver-update 570"},
|
||||
"amd":{"referer":"https://www.amd.com/","min_version":"32.0.32000.0","version":"26.9.2","url":"https://drivers.amd.com/drivers/whql-amd-software-adrenalin-edition-26.9.2-win10-win11-sep2026.exe","size":912345678,"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","hash_source":"https://www.amd.com/en/support/download/drivers.html","args":["-install","-silent"],"reboot_codes":[3010],"signer":"Advanced Micro Devices","linux_package":"amdgpu-install --usecase=opencl"},
|
||||
"intel":{"min_version":"32.0.101.9034","version":"32.0.101.9034","url":"https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe","size":932631144,"sha256":"72ba7eea08a0cc18650603976ff9433dfdf84d23d4cbbee662a4df9f2856ae98","hash_source":"https://www.intel.com/content/www/us/en/download/785597/","args":["-s"],"reboot_codes":[14,1014],"signer":"Intel","linux_package":"intel-opencl-icd"}}}"#;
|
||||
|
||||
fn table() -> Table {
|
||||
Table::parse(&serde_json::from_str(TABLE).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_part_by_part() {
|
||||
assert!(at_least(&parse_dotted("32.0.101.9034").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(at_least(&parse_dotted("32.0.101.9040").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(!at_least(&parse_dotted("32.0.101.6733").unwrap(), &parse_dotted("32.0.101.9034").unwrap()));
|
||||
assert!(at_least(&parse_dotted("581.57").unwrap(), &parse_dotted("570.00").unwrap()));
|
||||
assert!(!at_least(&parse_dotted("566.36").unwrap(), &parse_dotted("570").unwrap()));
|
||||
assert!(at_least(&parse_dotted("570").unwrap(), &parse_dotted("570.0.0").unwrap()));
|
||||
assert_eq!(parse_dotted("3683.0 (PAL,LC)"), None);
|
||||
assert_eq!(parse_dotted(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_parses_and_bad_rows_are_refused() {
|
||||
let t = table();
|
||||
assert_eq!(t.vendors.len(), 3);
|
||||
assert_eq!(t.entry("intel").unwrap().reboot_codes, vec![14, 1014]);
|
||||
assert_eq!(t.entry("nvidia").unwrap().args, vec!["-s", "-noreboot"]);
|
||||
assert!(!t.dry_run);
|
||||
let bad = |patch: &str, what: &str| {
|
||||
let txt = TABLE.replacen(patch, what, 1);
|
||||
let e = Table::parse(&serde_json::from_str(&txt).unwrap()).unwrap_err();
|
||||
e
|
||||
};
|
||||
assert!(bad("https://downloadmirror", "http://downloadmirror").contains("https"));
|
||||
assert!(bad("\"size\":932631144", "\"size\":0").contains("size"));
|
||||
assert!(bad("\"args\":[\"-s\"]", "\"args\":[\"-s; calc\"]").contains("args"));
|
||||
assert!(bad("\"signer\":\"Intel\"", "\"signer\":\"\"").contains("signer"));
|
||||
assert!(bad("\"args\":[\"-install\",\"-silent\"]", "\"referer\":\"http://x\",\"args\":[\"-install\",\"-silent\"]").contains("referer"));
|
||||
assert_eq!(t.entry("amd").unwrap().referer, "https://www.amd.com/");
|
||||
assert!(bad("\"min_version\":\"570.00\"", "\"min_version\":\"latest\"").contains("dotted"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {"apple": {}}})).unwrap_err().contains("vendor"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {}})).unwrap_err().contains("empty"));
|
||||
assert!(Table::parse(&serde_json::json!({"vendors": {"intel": null}})).unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
/// The offers per tier: a missing driver, an old one, a fine one, Apple, Linux and HiveOS.
|
||||
#[test]
|
||||
fn offers_name_the_version_the_size_and_the_reason() {
|
||||
let t = table();
|
||||
let o = offer_for("intel", "", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "missing");
|
||||
assert!(o.installable);
|
||||
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.");
|
||||
let o = offer_for("intel", "32.0.101.6733", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "old");
|
||||
assert_eq!(o.text, "Install the Intel Arc driver 32.0.101.9034 (889 MB). Driver 32.0.101.6733 is older than the 32.0.101.9034 the worker needs.");
|
||||
let o = offer_for("intel", "32.0.101.9034", Some(&t), "windows").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable, o.text.as_str()), ("fine", false, "Intel Arc driver 32.0.101.9034: fine."));
|
||||
let o = offer_for("nvidia", "617.42", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "fine");
|
||||
let o = offer_for("nvidia", "566.36", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.text, "Install the NVIDIA driver 617.42 (945 MB). Driver 566.36 is older than the 570.00 the worker needs.");
|
||||
let o = offer_for("amd", "32.0.21042.62", Some(&t), "windows").unwrap();
|
||||
assert_eq!(o.status, "old");
|
||||
assert!(o.text.starts_with("Install the AMD driver 26.9.2 (870 MB)."));
|
||||
// Apple: no step; Linux and HiveOS: the package, nothing installable
|
||||
let o = offer_for("apple", "", Some(&t), "macos").unwrap();
|
||||
assert_eq!((o.status.as_str(), o.installable), ("none", false));
|
||||
assert!(o.text.contains("no driver step"));
|
||||
let o = offer_for("nvidia", "", Some(&t), "linux").unwrap();
|
||||
assert_eq!(o.text, "Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).");
|
||||
assert!(!o.installable);
|
||||
let o = offer_for("nvidia", "566.36", Some(&t), "hive").unwrap();
|
||||
assert_eq!(o.text, "HiveOS: update the driver with nvidia-driver-update 570 (the worker needs 570.00 or newer).");
|
||||
let o = offer_for("intel", "", Some(&t), "macos").unwrap();
|
||||
assert_eq!(o.text, "macOS: no driver step.");
|
||||
// no table, or a vendor the table lacks: no offer, the row says nothing
|
||||
assert!(offer_for("nvidia", "", None, "windows").is_none());
|
||||
assert!(offer_for("other", "", Some(&t), "windows").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_and_signatures_read_as_the_vendor_means_them() {
|
||||
let t = table();
|
||||
let intel = t.entry("intel").unwrap();
|
||||
assert_eq!(exit_meaning(0, intel), (false, "INTEL driver 32.0.101.9034 installed.".into()));
|
||||
assert_eq!(exit_meaning(1014, intel).0, true);
|
||||
assert_eq!(exit_meaning(14, intel).1, "INTEL driver 32.0.101.9034 installed. Restart Windows to finish.");
|
||||
assert_eq!(exit_meaning(1007, intel), (false, "the INTEL installer exited with code 1007.".into()));
|
||||
assert!(authenticode_verdict("status=Valid\nsubject=CN=Intel Corporation, O=Intel Corporation, S=California, C=US\n", "Intel").is_ok());
|
||||
assert!(authenticode_verdict("status=NotSigned\nsubject=\n", "Intel").unwrap_err().contains("NotSigned"));
|
||||
assert!(authenticode_verdict("status=Valid\nsubject=CN=Someone Else\n", "Intel").unwrap_err().contains("Someone Else"));
|
||||
assert!(authenticode_verdict("", "Intel").unwrap_err().contains("unreadable"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -25,6 +25,31 @@ pub const POWER_STEPS_PCT: [u32; 6] = [100, 90, 80, 70, 60, 50];
|
|||
pub const CLOCK_STEPS_PCT: [u32; 7] = [100, 90, 80, 70, 60, 50, 45];
|
||||
/// A card's clock floor when the vendor reports none: this share of its maximum core clock.
|
||||
pub const CLOCK_FLOOR_PCT: u32 = 45;
|
||||
/// The measured efficient point per card (docs/bench-log.md: the 5090 at 308 W for 122 MH/s, the 9070 XT at 199 W for
|
||||
/// 18 MH/s), the tuner's ceiling. Rule (main, 7 October 2026, after PC 2 dropped nine minutes after the tuner asked its
|
||||
/// 5090 for 575 W with proving on the same card): the tuner never requests more than the card's measured efficient point
|
||||
/// unless Power control is on and the user raised the cap above the default; a card not in this table is held at the
|
||||
/// cap it runs at. A point below the vendor's minimum limit clamps up to that minimum (the 5090 floors at 400 W).
|
||||
pub const EFFICIENT_W: &[(&str, f64)] = &[("RTX 5090", 308.0), ("RX 9070 XT", 199.0)];
|
||||
/// The app's cap when the user chose none (engine.rs requested_watts); a cap above it is one the user raised.
|
||||
pub const DEFAULT_CAP_PCT: u32 = 80;
|
||||
|
||||
/// The measured efficient watts of a card by its name, when the table has it.
|
||||
pub fn efficient_watts(name: &str) -> Option<f64> {
|
||||
let n = name.to_ascii_uppercase();
|
||||
EFFICIENT_W.iter().find(|(k, _)| n.contains(&k.to_ascii_uppercase())).map(|(_, w)| *w)
|
||||
}
|
||||
|
||||
/// The tuner's power ceiling in watts for a card: its measured efficient point, or the cap it runs at when the table has
|
||||
/// no entry; the user's own cap instead when Power control is on and that cap is above the default (they raised it).
|
||||
pub fn power_ceiling(limits: &Limits, name: &str, before: Point, power_control: bool) -> f64 {
|
||||
let cap = Limits { power_ceiling_w: 0.0, ..limits.clone() }.watts_for(if before.power_pct == 0 { DEFAULT_CAP_PCT } else { before.power_pct });
|
||||
let raised = power_control && before.power_pct > DEFAULT_CAP_PCT;
|
||||
match efficient_watts(name) {
|
||||
Some(e) if !raised => e.min(cap),
|
||||
_ => cap,
|
||||
}
|
||||
}
|
||||
/// A point may lose this much rate against the fastest point and still win on MH per watt (the manifest can change it).
|
||||
pub const RATE_TOLERANCE_PCT: f64 = 1.0;
|
||||
/// A step whose hottest GPU reading reaches this is marked hot and cannot win (the engine aborts at 90).
|
||||
|
|
@ -55,9 +80,20 @@ pub struct Limits {
|
|||
/// clocks.max.mem); 0 = no memory knob (AMD through ADLX on RDNA 4 exposes none)
|
||||
pub mem_default_mhz: u32,
|
||||
pub mem_max_mhz: u32,
|
||||
/// the tuner's ceiling (power_ceiling), 0 = none: no step asks for more watts than this
|
||||
pub power_ceiling_w: f64,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// The ceiling as the vendor allows it: never below the card's minimum limit; 0 when there is none.
|
||||
pub fn ceiling(&self) -> f64 {
|
||||
if self.power_ceiling_w <= 0.0 { 0.0 } else { self.power_ceiling_w.max(self.power_min_w) }
|
||||
}
|
||||
/// The percent of the default that the ceiling is, for the first step of the power ladder.
|
||||
pub fn ceiling_pct(&self) -> u32 {
|
||||
let c = self.ceiling();
|
||||
if c <= 0.0 || self.power_default_w <= 0.0 { 100 } else { (c / self.power_default_w * 100.0).round().clamp(1.0, 100.0) as u32 }
|
||||
}
|
||||
pub fn clock_floor(&self) -> u32 {
|
||||
if self.clock_min_mhz > 0 {
|
||||
self.clock_min_mhz
|
||||
|
|
@ -88,6 +124,10 @@ impl Limits {
|
|||
if self.power_max_w > 0.0 {
|
||||
w = w.min(self.power_max_w);
|
||||
}
|
||||
let c = self.ceiling();
|
||||
if c > 0.0 {
|
||||
w = w.min(c);
|
||||
}
|
||||
w.round()
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +316,9 @@ impl Plan {
|
|||
pub fn full(limits: &Limits, before: Point, tolerance_pct: f64) -> Plan {
|
||||
let mut power = Vec::new();
|
||||
if limits.power_default_w > 0.0 {
|
||||
for pct in POWER_STEPS_PCT {
|
||||
// the ceiling first (its own percent), then the ladder; every step above it clamps to it and is dropped as a duplicate
|
||||
let top = if limits.ceiling() > 0.0 { vec![limits.ceiling_pct()] } else { Vec::new() };
|
||||
for pct in top.into_iter().chain(POWER_STEPS_PCT) {
|
||||
let w = limits.watts_for(pct);
|
||||
if power.last().map(|s: &Step| (s.watts - w).abs() < 0.5).unwrap_or(false) {
|
||||
continue;
|
||||
|
|
@ -906,6 +948,115 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
|
|||
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
|
||||
}
|
||||
|
||||
/// HH:MM UTC of a unix time (the day is not shown: "since 18:39 UTC").
|
||||
pub fn hhmm_utc(unix: f64) -> String {
|
||||
let s = unix.max(0.0) as u64 % 86_400;
|
||||
format!("{:02}:{:02}", s / 3600, (s % 3600) / 60)
|
||||
}
|
||||
|
||||
/// Horizon polish Q83: the one sentence every surface shows while finality is paused. The cause is the node's own
|
||||
/// (`reason`, with who holds it) when it carries one, else the plain two-thirds line; never the word "final".
|
||||
pub fn finality_paused_line(since_unix: f64, reason: &str, held_by: &str) -> String {
|
||||
let cause = if reason.trim().is_empty() {
|
||||
"under two thirds of the weight is signing".to_string()
|
||||
} else if held_by.trim().is_empty() {
|
||||
reason.trim().to_string()
|
||||
} else {
|
||||
format!("{} (held by {})", reason.trim(), held_by.trim())
|
||||
};
|
||||
format!("Finality paused since {} UTC: {cause}", hhmm_utc(since_unix))
|
||||
}
|
||||
|
||||
/// The node's structured finality fields on igneum_getProvingStatus (the node lane, 0.3.16): "finalityReason"
|
||||
/// (empty when live), "finalityProvisional", "heldBy" {tableIndex, stayersShareBps, expiresDaa} or null,
|
||||
/// "pausedSinceMs" or null. Returns (reason, held-by words, provisional, paused_since unix seconds).
|
||||
pub struct FinalityStatus {
|
||||
/// the node's own word on whether finality is live (b2e21447: finalityActive); None on an older node
|
||||
pub active: Option<bool>,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub provisional: bool,
|
||||
pub paused_since: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn parse_finality_status(v: &serde_json::Value) -> Option<FinalityStatus> {
|
||||
// the node's words begin "paused: ..." (b2e21447); the app's sentence already says "Finality paused since", so
|
||||
// that prefix goes; a reason that already names who holds it ("held by weight table 7, ...") is not repeated
|
||||
let reason = v.get("finalityReason")?.as_str().unwrap_or("").trim().trim_start_matches("paused:").trim().to_string();
|
||||
let active = v.get("finalityActive").and_then(|b| b.as_bool());
|
||||
let provisional = v.get("finalityProvisional").and_then(|b| b.as_bool()).unwrap_or(false);
|
||||
let held_by = match v.get("heldBy") {
|
||||
Some(h) if h.is_object() => {
|
||||
let idx = h.get("tableIndex").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let bps = h.get("stayersShareBps").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
let exp = h.get("expiresDaa").and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
format!("weight table {idx}, {}.{:02}% still signing, expires at DAA {exp}", bps / 100, bps % 100)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let held_by = if reason.contains("held by") { String::new() } else { held_by };
|
||||
let paused_since = v.get("pausedSinceMs").and_then(|x| x.as_u64()).filter(|ms| *ms > 0).map(|ms| ms as f64 / 1000.0);
|
||||
Some(FinalityStatus { active, reason, held_by, provisional, paused_since })
|
||||
}
|
||||
|
||||
/// The node's pause line, when it carries one: `... finality_reason=<words or "quoted words"> held_by=<id>`.
|
||||
/// Returns (reason, held_by); None when the line is not one.
|
||||
pub fn parse_finality_line(text: &str) -> Option<(String, String)> {
|
||||
let i = text.find("finality_reason=")?;
|
||||
let rest = &text[i + "finality_reason=".len()..];
|
||||
let (reason, after) = if let Some(q) = rest.strip_prefix('"') {
|
||||
let end = q.find('"')?;
|
||||
(q[..end].to_string(), &q[end + 1..])
|
||||
} else {
|
||||
let end = rest.find(' ').unwrap_or(rest.len());
|
||||
(rest[..end].replace('_', " "), &rest[end..])
|
||||
};
|
||||
let held_by = after.find("held_by=").map(|j| after[j + 8..].split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();
|
||||
Some((reason, held_by))
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
|
||||
/// `max_age_s` old (a stale reading is not a draw).
|
||||
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
|
||||
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
|
||||
}
|
||||
|
||||
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
|
||||
pub fn wei_from_hex(hex: &str) -> Option<String> {
|
||||
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
|
||||
if h.is_empty() {
|
||||
return Some("0".into());
|
||||
}
|
||||
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
|
||||
}
|
||||
|
||||
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
|
||||
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
|
||||
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
|
||||
}
|
||||
|
||||
/// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card
|
||||
/// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD
|
||||
/// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused.
|
||||
pub fn may_set(vendor: &str, tune_control: bool, amd_ordinal: i64) -> bool {
|
||||
match vendor {
|
||||
"nvidia" => tune_control,
|
||||
"amd" => tune_control && amd_ordinal >= 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// (E) The row's words while a back-off from a failed tune holds: when the retry comes and why it stopped.
|
||||
pub fn retry_note(remaining_s: u64, last_note: &str) -> String {
|
||||
let mins = (remaining_s + 59) / 60;
|
||||
let why = last_note.trim_start_matches("tuning stopped: ").trim_start_matches("tuning: ").trim();
|
||||
if why.is_empty() {
|
||||
format!("tuning: retry in {mins} min")
|
||||
} else {
|
||||
format!("tuning: retry in {mins} min ({why})")
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
|
||||
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
|
||||
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
|
||||
|
|
@ -942,7 +1093,71 @@ mod tests {
|
|||
/// PC 1's RTX 5090 (nvidia-smi, 4 and 5 October 2026): default 575 W, min 400 W, max 600 W; clocks.max.gr is
|
||||
/// read at the first tune (3,090 MHz is the shape used here, not a measurement).
|
||||
fn l5090() -> Limits {
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001 }
|
||||
Limits { power_default_w: 575.0, power_min_w: 400.0, power_max_w: 600.0, clock_max_mhz: 3090, clock_min_mhz: 0, mem_default_mhz: 13801, mem_max_mhz: 14001, power_ceiling_w: 0.0 }
|
||||
}
|
||||
|
||||
fn with_ceiling(name: &str, before: Point, power_control: bool) -> Limits {
|
||||
let mut l = l5090();
|
||||
l.power_ceiling_w = power_ceiling(&l, name, before, power_control);
|
||||
l
|
||||
}
|
||||
fn max_watts(plan: &Plan) -> f64 {
|
||||
let mut rows = Vec::new();
|
||||
let mut top: f64 = 0.0;
|
||||
while let Some(s) = plan.next(&rows) {
|
||||
top = top.max(s.watts);
|
||||
rows.push(row_at(s.point, s.watts, 100.0));
|
||||
if rows.len() > 40 { break; }
|
||||
}
|
||||
top
|
||||
}
|
||||
|
||||
/// PC 2, 7 October 2026: the tuner asked the 5090 for 100% (575 W) while proving ran on the same card; the PC dropped
|
||||
/// nine minutes later. The known-failed shape first: without a ceiling the full plan opens at 575 W.
|
||||
#[test]
|
||||
fn without_a_ceiling_the_full_plan_asks_the_5090_for_575_w() {
|
||||
let plan = Plan::full(&l5090(), Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
|
||||
assert_eq!(plan.next(&[]).unwrap().watts, 575.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tuner_never_asks_above_the_measured_efficient_point() {
|
||||
// the 5090's measured point is 308 W; the vendor's minimum is 400 W, so the ceiling clamps up to 400
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
|
||||
assert_eq!(l.power_ceiling_w, 308.0);
|
||||
assert_eq!(l.ceiling(), 400.0);
|
||||
let plan = Plan::full(&l, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0);
|
||||
let first = plan.next(&[]).unwrap();
|
||||
assert_eq!((first.watts, first.point.power_pct, first.kind), (400.0, 70, Kind::Power), "one power step at the ceiling, with its own percent");
|
||||
assert_eq!(plan.len(), 1 + 6, "one power step (every ladder step clamps to 400 W) and the six clock steps");
|
||||
assert!(max_watts(&plan) <= 400.0, "no step above the ceiling");
|
||||
// the confirm plan and the climb honour it too: a fleet prior at 100% is clamped
|
||||
let prior = Point { clock_mhz: 0, power_pct: 100, mem_mhz: 0 };
|
||||
assert!(max_watts(&Plan::confirm(&l, prior, Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, 1.0)) <= 400.0);
|
||||
assert!(max_watts(&Plan::climb(&l, prior, Goal::Efficiency, 1.0)) <= 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_control_on_with_a_cap_the_user_raised_lifts_the_ceiling_to_that_cap() {
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, true);
|
||||
assert_eq!(l.ceiling(), 518.0, "the user's 90% (518 W), never the vendor's 575 W");
|
||||
assert!(max_watts(&Plan::full(&l, Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, 1.0)) <= 518.0);
|
||||
// Power control on at the default cap is not a raise: the measured point holds
|
||||
let d = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: DEFAULT_CAP_PCT, mem_mhz: 0 }, true);
|
||||
assert_eq!(d.ceiling(), 400.0);
|
||||
// a raised cap with Power control OFF does not count
|
||||
let off = with_ceiling("NVIDIA GeForce RTX 5090", Point { clock_mhz: 0, power_pct: 90, mem_mhz: 0 }, false);
|
||||
assert_eq!(off.ceiling(), 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_not_in_the_table_is_held_at_the_cap_it_runs_at() {
|
||||
let l = with_ceiling("NVIDIA GeForce RTX 4070", Point { clock_mhz: 0, power_pct: 70, mem_mhz: 0 }, false);
|
||||
assert_eq!(l.ceiling(), 403.0, "70% of 575 W, the card's own cap");
|
||||
let zero = with_ceiling("NVIDIA GeForce RTX 4070", Point::default(), false);
|
||||
assert_eq!(zero.ceiling(), 460.0, "no chosen cap: the app's default 80%");
|
||||
assert_eq!(efficient_watts("AMD Radeon RX 9070 XT"), Some(199.0));
|
||||
assert_eq!(efficient_watts("gfx1036"), None);
|
||||
}
|
||||
|
||||
fn row_at(p: Point, watts: f64, mhs: f64) -> Row {
|
||||
|
|
@ -1007,6 +1222,74 @@ mod tests {
|
|||
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
|
||||
let now = 1_791_300_000.0;
|
||||
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
|
||||
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
|
||||
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
|
||||
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
|
||||
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
|
||||
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0x"), Some("0".into()));
|
||||
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
|
||||
assert_eq!(wei_from_hex("soon"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_finality_pause_line_names_the_time_and_the_cause_and_never_says_final() {
|
||||
// 6 October 2026 18:39:00Z
|
||||
let since = 1_791_311_940.0;
|
||||
assert_eq!(hhmm_utc(since), "18:39");
|
||||
let plain = finality_paused_line(since, "", "");
|
||||
assert_eq!(plain, "Finality paused since 18:39 UTC: under two thirds of the weight is signing");
|
||||
assert!(!plain.to_ascii_lowercase().contains("final "), "no 'final' word while paused: {plain}");
|
||||
assert_eq!(finality_paused_line(since, "a checkpoint vote is split", "ae432dc7"), "Finality paused since 18:39 UTC: a checkpoint vote is split (held by ae432dc7)");
|
||||
assert_eq!(parse_finality_line("1791311940 finality paused finality_reason=\"vote split at index 412\" held_by=ae432dc7"), Some(("vote split at index 412".into(), "ae432dc7".into())));
|
||||
assert_eq!(parse_finality_line("x finality_reason=under_two_thirds"), Some(("under two thirds".into(), String::new())));
|
||||
assert_eq!(parse_finality_line("status: accepted 3 blocks"), None);
|
||||
// the structured carrier on igneum_getProvingStatus (the node lane's field names)
|
||||
// the node's own words (b2e21447): "paused: under two thirds ... ({p}%; the table frozen at lock {j} has {q}% signing)"
|
||||
let v: serde_json::Value = serde_json::from_str(r#"{"v1":{"active":true},"finalityActive":false,"finalityReason":"paused: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)","finalityProvisional":false,"heldBy":null,"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let f = parse_finality_status(&v).unwrap();
|
||||
assert_eq!((f.active, f.provisional, f.paused_since), (Some(false), false, Some(1_791_311_940.0)));
|
||||
assert_eq!(finality_paused_line(f.paused_since.unwrap(), &f.reason, &f.held_by), "Finality paused since 18:39 UTC: under two thirds of the weight is signing (61%; the table frozen at lock 7 has 58% signing)");
|
||||
// the held-by form: the reason names the table itself, so heldBy is not repeated
|
||||
let v2: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: held by weight table 7, 61% of it signing, expires at DAA 205000","finalityProvisional":true,"heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000},"pausedSinceMs":1791311940000}"#).unwrap();
|
||||
let g = parse_finality_status(&v2).unwrap();
|
||||
assert!(g.provisional && g.held_by.is_empty());
|
||||
assert_eq!(finality_paused_line(g.paused_since.unwrap(), &g.reason, &g.held_by), "Finality paused since 18:39 UTC: held by weight table 7, 61% of it signing, expires at DAA 205000");
|
||||
// a reason without the table words keeps the heldBy suffix
|
||||
let v3: serde_json::Value = serde_json::from_str(r#"{"finalityActive":false,"finalityReason":"paused: no checkpoint determined above the latest lock","heldBy":{"tableIndex":7,"stayersShareBps":6150,"expiresDaa":205000}}"#).unwrap();
|
||||
assert_eq!(parse_finality_status(&v3).unwrap().held_by, "weight table 7, 61.50% still signing, expires at DAA 205000");
|
||||
// live finality: an empty reason, null heldBy, null pausedSinceMs
|
||||
let live: serde_json::Value = serde_json::from_str(r#"{"finalityReason":"","finalityProvisional":false,"heldBy":null,"pausedSinceMs":null}"#).unwrap();
|
||||
let l = parse_finality_status(&live).unwrap();
|
||||
assert!(l.reason.is_empty() && l.held_by.is_empty() && !l.provisional && l.paused_since.is_none() && l.active.is_none());
|
||||
// a node before 0.3.16 carries none of it
|
||||
assert!(parse_finality_status(&serde_json::json!({"v1":{"active":true}})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
|
||||
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
|
||||
assert_eq!(goal_for("rate", "balanced"), Goal::MaxRate);
|
||||
assert_eq!(goal_for("efficiency", "rate"), Goal::Efficiency);
|
||||
assert_eq!(goal_for(" ", "efficiency"), Goal::Efficiency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() {
|
||||
assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only");
|
||||
assert!(may_set("amd", true, 1));
|
||||
assert!(!may_set("amd", true, -1), "no ADLX ordinal");
|
||||
assert!(!may_set("nvidia", false, -1), "Power control off: measure only");
|
||||
assert!(may_set("nvidia", true, -1));
|
||||
assert!(!may_set("apple", true, -1));
|
||||
assert_eq!(retry_note(3599, "tuning stopped: the card refused the setting (see the log)"), "tuning: retry in 60 min (the card refused the setting (see the log))");
|
||||
assert_eq!(retry_note(30, ""), "tuning: retry in 1 min");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
|
||||
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
|
||||
|
|
@ -1081,7 +1364,7 @@ mod tests {
|
|||
fn a_fault_during_a_step_reverts_it_and_the_run_goes_on() {
|
||||
let t0 = Instant::now();
|
||||
let timing = Timing { settle: Duration::from_secs(2), hold: Duration::from_secs(4), apply: Duration::from_secs(30) };
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0 };
|
||||
let limits = Limits { power_default_w: 300.0, power_min_w: 150.0, power_max_w: 300.0, clock_max_mhz: 0, clock_min_mhz: 0, mem_default_mhz: 0, mem_max_mhz: 0, power_ceiling_w: 0.0 };
|
||||
let plan = Plan::full(&limits, Point { clock_mhz: 0, power_pct: 80, mem_mhz: 0 }, 1.0);
|
||||
let mut run = Run::new(0, "0", "c", plan, 240.0, false, timing, t0);
|
||||
let mut t = t0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
164
app/igneum-app/src/execrpc.rs
Normal file
164
app/igneum-app/src/execrpc.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! The one path to the node's execution-layer JSON-RPC (ledger N7, 7 October 2026, main's rule for 0.3.19).
|
||||
//!
|
||||
//! A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes
|
||||
//! the record vector is asked while its exec follower holds no record: `rpc.rs` indexes `records[0]` (or slices
|
||||
//! `records[1..=0]`) on an empty vector, the panic hook exits the process, and the app restarts it. PC 1 crash-looped on
|
||||
//! two callers in one night (the clock sample's eth_getBlockByNumber, then the prover's igneum_getAssignedShards after the
|
||||
//! node read synced seconds before a slow follower loaded). The node-side fix ships with the 0.3.20 node; a 0.3.19 app on a
|
||||
//! 0.3.17 node must be safe by itself, so every exec RPC call the app makes goes through [`call`]: a method in
|
||||
//! [`SAFE_ON_EMPTY`] goes out at once; any other waits until igneum_getExecStatus reports an executed tip. The unit test
|
||||
//! below enumerates the callers: no other file may build an exec JSON-RPC request, and every method name in the tree must
|
||||
//! be classified here, so a new caller or method cannot bypass the gate.
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Methods that index nothing on an empty exec state (read from the 0.3.17 node's rpc.rs): safe at any time.
|
||||
pub const SAFE_ON_EMPTY: &[&str] = &[
|
||||
"eth_chainId", "eth_blockNumber", "eth_syncing", "igneum_getExecStatus", "igneum_getProvingStatus", "igneum_getNodeInfo",
|
||||
// the engine's balance read (0.3.21): an account lookup at the latest state, nothing indexed by block number
|
||||
"eth_getBalance",
|
||||
];
|
||||
|
||||
/// Methods the app sends that resolve a block number, index or slice the record vector, or simulate at a block: held until
|
||||
/// the follower holds a record. Every method literal outside this module must be in one of the two lists.
|
||||
pub const GATED: &[&str] = &[
|
||||
"eth_getBlockByNumber", "igneum_getAssignedShards", "igneum_getProofRecords", "igneum_getSegmentRecords", "igneum_getSegmentStatement",
|
||||
"igneum_getProofBytes", "igneum_getSegmentProofBytes", "igneum_exportSegments", "igneum_submitProofRecord", "igneum_submitSegmentRecord",
|
||||
"igneum_getFinalityWeights",
|
||||
// 0.3.21's live page reads recent blocks by number through the same path (src/live.rs); held like the rest
|
||||
"igneum_getRecentBlocks",
|
||||
];
|
||||
|
||||
/// One JSON-RPC POST to 127.0.0.1:<evm_port> through curl (the engine carries no HTTP client); the body goes through a file
|
||||
/// so a large export request is not an argument. The reply's `result` (null allowed), or the error's message.
|
||||
fn post(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-rpc-{}-{}-{}.json", std::process::id(), method, crate::platform::unix_now_f() as u64));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
let url = format!("http://127.0.0.1:{evm_port}");
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().max(1).to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &format!("@{}", tmp.display())]),
|
||||
None,
|
||||
timeout + Duration::from_secs(2),
|
||||
);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||
// an empty or unparsable body is no answer (a stopped node's socket still accepts the connection and curl
|
||||
// returns nothing inside its own limit; the probe must read that as silence, 7 October 2026)
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: the node's RPC did not answer ({e})"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||
/// unreachable node reads false: the gated call waits rather than asks.
|
||||
pub fn has_record(evm_port: u16) -> bool {
|
||||
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
Ok(r) => status_has_record(&r),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reading of an igneum_getExecStatus result: a record is held when executedTipHash is a non-empty string.
|
||||
pub fn status_has_record(result: &Value) -> bool {
|
||||
result.get("executedTipHash").and_then(|h| h.as_str()).map(|h| !h.is_empty()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The engine's readiness probe (every 5 s): did the node's RPC answer at all, and does the follower hold a record.
|
||||
/// The first is the node watchdog's sign of life; the second, with `synced`, is the workers' start gate.
|
||||
pub fn probe(evm_port: u16) -> (bool, bool) {
|
||||
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
Ok(r) => (true, status_has_record(&r)),
|
||||
Err(e) => (!e.contains("did not answer"), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The gate: a safe method goes out; a gated one waits for a record; an unclassified method is refused (add it to a list).
|
||||
pub fn call(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
if SAFE_ON_EMPTY.contains(&method) {
|
||||
return post(evm_port, method, params, timeout);
|
||||
}
|
||||
if !GATED.contains(&method) {
|
||||
return Err(format!("{method}: not classified in execrpc (safe on an empty state, or gated); add it before calling"));
|
||||
}
|
||||
if !has_record(evm_port) {
|
||||
return Err(format!("{method}: the node's execution layer holds no record yet"));
|
||||
}
|
||||
post(evm_port, method, params, timeout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_reading() {
|
||||
assert!(status_has_record(&json!({"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec"})));
|
||||
assert!(!status_has_record(&json!({"executedTip":"0x0","executedTipHash":null})));
|
||||
assert!(!status_has_record(&json!({})));
|
||||
assert!(!status_has_record(&json!({"executedTipHash":""})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_are_disjoint_and_sorted_enough() {
|
||||
for m in GATED {
|
||||
assert!(!SAFE_ON_EMPTY.contains(m), "{m} in both lists");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ledger N7: every exec JSON-RPC request the app builds goes through this module, and every exec method name in the
|
||||
/// tree is classified here. A new direct caller (a file that builds a "jsonrpc" POST) or an unclassified method fails.
|
||||
#[test]
|
||||
fn every_caller_goes_through_the_gate() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
// every eth_* or igneum_* identifier on a line (a hand scanner: no regex crate in this binary)
|
||||
fn methods_in(line: &str) -> Vec<String> {
|
||||
// ASCII-only scan over char boundaries: a token of [A-Za-z0-9_] that starts with eth_ or igneum_
|
||||
let mut out = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut flush = |t: &mut String| {
|
||||
if t.starts_with("eth_") || t.starts_with("igneum_") { out.push(t.clone()); }
|
||||
t.clear();
|
||||
};
|
||||
for ch in line.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' { token.push(ch); } else { flush(&mut token); }
|
||||
}
|
||||
flush(&mut token);
|
||||
out
|
||||
}
|
||||
let mut offenders = Vec::new();
|
||||
let mut unclassified = Vec::new();
|
||||
for entry in std::fs::read_dir(&src).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; }
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// a JSON-RPC REQUEST names a method next to "jsonrpc" (replies and test fixtures carry "result" or "error");
|
||||
// only this module may build one
|
||||
if name != "execrpc.rs" {
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let l = line.replace('\\', "");
|
||||
if l.contains("\"jsonrpc\"") && l.contains("\"method\"") && !l.contains("\"result\"") && !l.contains("\"error\"") {
|
||||
offenders.push(format!("{name}:{}", i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if line.trim_start().starts_with("//") { continue; }
|
||||
for m in methods_in(line) {
|
||||
let m = m.as_str();
|
||||
// identifiers that are not RPC methods: crate and file names (igneum_app, igneum_miner, ...) carry no camel-case method part
|
||||
let looks_like_method = m.contains("_get") || m.contains("_submit") || m.contains("_export") || m.contains("_estimate") || m.contains("_send") || m == "eth_chainId" || m == "eth_blockNumber" || m == "eth_syncing";
|
||||
if looks_like_method && !SAFE_ON_EMPTY.contains(&m) && !GATED.contains(&m) {
|
||||
unclassified.push(format!("{name}:{}: {m}", i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "exec JSON-RPC built outside execrpc.rs: {offenders:?}");
|
||||
assert!(unclassified.is_empty(), "exec methods not classified in execrpc.rs: {unclassified:?}");
|
||||
}
|
||||
}
|
||||
242
app/igneum-app/src/extnode.rs
Normal file
242
app/igneum-app/src/extnode.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
|
||||
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
|
||||
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
|
||||
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
|
||||
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
|
||||
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
|
||||
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
|
||||
//! too, read every 30 s instead of parsed from a stdout the app does not own.
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// What the other node answered: None where it could not answer.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Check {
|
||||
pub chain_id: Option<u64>,
|
||||
pub digest: Option<String>,
|
||||
pub network: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
|
||||
pub overrides: Option<Value>,
|
||||
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
|
||||
pub pow_engine: Option<String>,
|
||||
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
|
||||
/// None on a node without the object, and the app assumes 3,600 (1 bps)
|
||||
pub merge_depth: Option<u64>,
|
||||
}
|
||||
|
||||
/// The decision for the port-collision path.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Decision {
|
||||
pub use_it: bool,
|
||||
/// match | mismatch | unknown
|
||||
pub verdict: &'static str,
|
||||
/// the event line, in a user's words
|
||||
pub line: String,
|
||||
}
|
||||
|
||||
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
|
||||
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
|
||||
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
|
||||
|
||||
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
|
||||
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
|
||||
let mut checked = false;
|
||||
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
|
||||
checked = true;
|
||||
if a != b {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
|
||||
checked = true;
|
||||
if a != b {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
|
||||
}
|
||||
}
|
||||
if theirs.pow_engine.as_deref() == Some("stub") {
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
|
||||
}
|
||||
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
|
||||
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
|
||||
checked = true;
|
||||
for (k, v) in o {
|
||||
if t.get(k) != Some(v) {
|
||||
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
|
||||
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked {
|
||||
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
|
||||
} else {
|
||||
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
|
||||
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
|
||||
pub const TAKEOVER_WAIT_S: f64 = 60.0;
|
||||
|
||||
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Step {
|
||||
/// the other node still answers on the port
|
||||
Stay,
|
||||
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
|
||||
Gone { for_s: f64 },
|
||||
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
|
||||
TakeOver,
|
||||
}
|
||||
|
||||
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
|
||||
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
|
||||
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
|
||||
if port_open {
|
||||
*gone_since = None;
|
||||
return Step::Stay;
|
||||
}
|
||||
let since = *gone_since.get_or_insert(now_s);
|
||||
let for_s = now_s - since;
|
||||
if for_s >= TAKEOVER_WAIT_S {
|
||||
*gone_since = None;
|
||||
Step::TakeOver
|
||||
} else {
|
||||
Step::Gone { for_s }
|
||||
}
|
||||
}
|
||||
|
||||
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
|
||||
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
|
||||
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||
crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null())
|
||||
}
|
||||
|
||||
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
|
||||
pub fn probe(evm_port: u16) -> Check {
|
||||
let mut c = Check::default();
|
||||
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
|
||||
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
|
||||
}
|
||||
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
|
||||
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
|
||||
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
|
||||
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
|
||||
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
|
||||
.collect();
|
||||
files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
let mut removed = 0;
|
||||
for (_, p) in files.into_iter().skip(keep) {
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
|
||||
|
||||
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
|
||||
#[test]
|
||||
fn a_node_on_our_rules_is_used_and_said_so() {
|
||||
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
|
||||
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
|
||||
assert!(d.use_it);
|
||||
assert_eq!(d.verdict, "match");
|
||||
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_on_another_network_or_other_rules_is_refused() {
|
||||
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
|
||||
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.verdict, "mismatch");
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
|
||||
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
|
||||
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
|
||||
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
|
||||
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
|
||||
// a stub engine is refused whatever else matches
|
||||
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
|
||||
// an override we have that the other node lacks is a mismatch too
|
||||
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
|
||||
// the network name (node lane, igneum_getNodeInfo.network) must be equal
|
||||
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
|
||||
assert!(!d.use_it);
|
||||
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
|
||||
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
|
||||
let d = decide(26611, Some(&ours()), None, &Check::default());
|
||||
assert!(d.use_it);
|
||||
assert_eq!(d.verdict, "unknown");
|
||||
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
|
||||
// a chain id alone, matching, is a check
|
||||
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
|
||||
}
|
||||
|
||||
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
|
||||
#[test]
|
||||
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
|
||||
let mut gone = None;
|
||||
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
|
||||
assert_eq!(gone, None);
|
||||
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
|
||||
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
|
||||
assert_eq!(gone, None, "the memory resets once the app has its own node");
|
||||
// a node that comes back inside the wait is kept, and the wait starts over
|
||||
let mut gone = None;
|
||||
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
|
||||
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
|
||||
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_keeps_the_newest_logs() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..6 {
|
||||
let p = dir.join(format!("node-2026100{i}.log"));
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
|
||||
let f = std::fs::File::options().write(true).open(&p).unwrap();
|
||||
f.set_modified(t).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
|
||||
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
|
||||
assert_eq!(prune_logs(&dir, "node-", 4), 2);
|
||||
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
left.sort();
|
||||
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
487
app/igneum-app/src/heat.rs
Normal file
487
app/igneum-app/src/heat.rs
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
//! Ember Heat (mission item 7, 7 October 2026, docs/plans/ember-heat.md): the card is a heater that also earns. Heat mode
|
||||
//! holds a room temperature, or a schedule of them, and the hash follows the duty cycle: the cards mine for a share of
|
||||
//! every ten-minute period and rest for the rest of it. The chain sees a miner with a schedule, nothing else.
|
||||
//!
|
||||
//! The temperature source is what the machine has: a reading the miner types from their own thermometer (fresh for
|
||||
//! two hours), else the card's own sensor once the card has rested long enough to cool to the room plus an idle
|
||||
//! offset (default 8 degrees, approximate; learned from a typed reading taken while the card rests). No hardware
|
||||
//! the app does not have. Without any reading the loop heats 70 percent of every period and says so.
|
||||
//!
|
||||
//! The loop is proportional plus integral, decided once per period on the reading at the period's start: the
|
||||
//! integral carries the steady-state share of heat the room needs, the proportional term pulls it back when the
|
||||
//! room drifts. The engine (src/engine.rs, `tick_heat`) owns the processes: it stops the miners for a rest and
|
||||
//! re-arms them for the heating slice; nothing here touches a card. The log carries one `HEAT` line every 30 s and
|
||||
//! tools/heat-gate.mjs reads it for the PC 1 gate (held within 1 degree for 4 hours, hash following the duty).
|
||||
|
||||
/// One period: the heating slice first, the rest after it.
|
||||
pub const PERIOD_S: f64 = 600.0;
|
||||
/// Duty per degree under the set point (2 degrees under = a full period of heat).
|
||||
pub const KP: f64 = 0.5;
|
||||
/// Duty per degree per period added to the integral (the steady-state share).
|
||||
pub const KI: f64 = 0.05;
|
||||
/// The card sensor stands for the room only after this long at rest (the die cools toward the room plus the idle
|
||||
/// offset; what is left of the cooling tail is taken off by its slope, COOL_TAU_S).
|
||||
pub const SETTLE_S: f64 = 180.0;
|
||||
/// The cooling tail of a stopped card as one time constant, seconds, approximate: the estimate adds tau times the
|
||||
/// (negative) slope of the sensor, which is exact for a single exponential and partial otherwise.
|
||||
pub const COOL_TAU_S: f64 = 60.0;
|
||||
/// A typed room reading is the room for this long.
|
||||
pub const TYPED_FRESH_S: f64 = 7200.0;
|
||||
/// An idle card's sensor above the room, degrees, approximate, until a typed reading teaches the real offset.
|
||||
pub const OFFSET_DEFAULT_C: f64 = 8.0;
|
||||
/// The stated error of the card-sensor estimate, degrees.
|
||||
pub const OFFSET_ERROR_C: f64 = 3.0;
|
||||
/// With the card sensor as the only source, every period keeps a rest long enough to read the room.
|
||||
pub const CARD_DUTY_MAX: f64 = 1.0 - SETTLE_S / PERIOD_S;
|
||||
/// With no reading at all: heat this share of every period (the rest long enough for the card to read the room) and say why.
|
||||
pub const FIND_DUTY: f64 = CARD_DUTY_MAX;
|
||||
/// The set point the app accepts, degrees.
|
||||
pub const SET_MIN_C: f64 = 5.0;
|
||||
pub const SET_MAX_C: f64 = 30.0;
|
||||
/// The log line and the gate tool's sample spacing.
|
||||
pub const LOG_EVERY_S: f64 = 30.0;
|
||||
|
||||
/// One entry of a schedule: from this minute of the day the set point is `set_c`, until the next entry.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Slot {
|
||||
pub minute: u32,
|
||||
pub set_c: f64,
|
||||
}
|
||||
|
||||
/// "06:00 20, 22:00 16" to slots, sorted by minute. Empty text = no schedule (the one set point all day).
|
||||
pub fn parse_schedule(text: &str) -> Result<Vec<Slot>, String> {
|
||||
let mut out = Vec::new();
|
||||
for part in text.split(|c| c == ',' || c == ';' || c == '\n') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut it = part.split_whitespace();
|
||||
let (Some(time), Some(temp)) = (it.next(), it.next()) else {
|
||||
return Err(format!("\"{part}\": write a time and a temperature, for example 06:00 20"));
|
||||
};
|
||||
let (h, m) = time.split_once(':').ok_or_else(|| format!("\"{time}\": a time is HH:MM"))?;
|
||||
let h: u32 = h.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
|
||||
let m: u32 = m.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
|
||||
if h > 23 || m > 59 {
|
||||
return Err(format!("\"{time}\": a time is HH:MM, 00:00 to 23:59"));
|
||||
}
|
||||
let set_c: f64 = temp.trim_end_matches("°C").trim_end_matches('C').parse().map_err(|_| format!("\"{temp}\": a temperature is a number of degrees"))?;
|
||||
if !(SET_MIN_C..=SET_MAX_C).contains(&set_c) {
|
||||
return Err(format!("{set_c} degrees: the set point is {SET_MIN_C:.0} to {SET_MAX_C:.0}"));
|
||||
}
|
||||
out.push(Slot { minute: h * 60 + m, set_c });
|
||||
}
|
||||
out.sort_by_key(|s| s.minute);
|
||||
out.dedup_by_key(|s| s.minute);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Slots back to the text the settings page shows.
|
||||
pub fn schedule_text(slots: &[Slot]) -> String {
|
||||
slots.iter().map(|s| format!("{:02}:{:02} {}", s.minute / 60, s.minute % 60, trim_c(s.set_c))).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
fn trim_c(c: f64) -> String {
|
||||
if (c - c.round()).abs() < 0.05 { format!("{:.0}", c) } else { format!("{:.1}", c) }
|
||||
}
|
||||
|
||||
/// The set point in force at a minute of the day: the latest slot at or before it; before the first slot, the last
|
||||
/// slot of the day (the schedule wraps at midnight). No slots: the default.
|
||||
pub fn set_point_at(default_c: f64, slots: &[Slot], minute_of_day: u32) -> f64 {
|
||||
if slots.is_empty() {
|
||||
return default_c;
|
||||
}
|
||||
slots.iter().rev().find(|s| s.minute <= minute_of_day).or_else(|| slots.last()).map(|s| s.set_c).unwrap_or(default_c)
|
||||
}
|
||||
|
||||
/// The minute of the day in the schedule's own clock: unix seconds plus the window's offset east of UTC in minutes.
|
||||
pub fn minute_of_day(unix: f64, tz_east_min: i32) -> u32 {
|
||||
let local = unix as i64 + tz_east_min as i64 * 60;
|
||||
((local.rem_euclid(86_400)) / 60) as u32
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
Typed,
|
||||
Card,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Source::Typed => "typed",
|
||||
Source::Card => "card",
|
||||
Source::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the loop knows about the room right now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Reading {
|
||||
pub room_c: f64,
|
||||
pub source: Source,
|
||||
/// the stated error of the estimate, degrees (0 for a typed reading: the thermometer is the miner's)
|
||||
pub error_c: f64,
|
||||
/// how old the reading is, seconds
|
||||
pub age_s: f64,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
pub fn none() -> Reading {
|
||||
Reading { room_c: 0.0, source: Source::None, error_c: 0.0, age_s: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The room estimate: a typed reading while it is fresh, else the coolest card's sensor minus the idle offset once
|
||||
/// the cards have rested SETTLE_S (the cooling tail still in the sensor taken off by its slope), else nothing.
|
||||
/// `typed` = (degrees, unix s typed); `card_c` = 0 when no card reports; `card_slope` = degrees per second over the
|
||||
/// last half minute (negative while cooling; a rising sensor is not corrected).
|
||||
pub fn room(now: f64, typed: Option<(f64, f64)>, card_c: f64, card_slope: f64, rest_for_s: f64, offset_c: f64) -> Reading {
|
||||
if let Some((c, at)) = typed {
|
||||
if c > -50.0 && at > 0.0 && now - at < TYPED_FRESH_S {
|
||||
return Reading { room_c: c, source: Source::Typed, error_c: 0.0, age_s: (now - at).max(0.0) };
|
||||
}
|
||||
}
|
||||
if card_c > 0.0 && rest_for_s >= SETTLE_S {
|
||||
let off = if offset_c > 0.0 { offset_c } else { OFFSET_DEFAULT_C };
|
||||
let tail = COOL_TAU_S * card_slope.min(0.0);
|
||||
return Reading { room_c: card_c + tail - off, source: Source::Card, error_c: OFFSET_ERROR_C, age_s: 0.0 };
|
||||
}
|
||||
Reading::none()
|
||||
}
|
||||
|
||||
/// The slope of sensor samples (unix s, degrees) at the END of the window, degrees per second: the least-squares
|
||||
/// line (its slope belongs to the window's middle) brought forward by the exponential tail's own decay over half
|
||||
/// the window. 0 with fewer than two samples.
|
||||
pub fn slope_of<I: Iterator<Item = (f64, f64)>>(samples: I) -> f64 {
|
||||
let v: Vec<(f64, f64)> = samples.collect();
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let n = v.len() as f64;
|
||||
let (mt, mc) = (v.iter().map(|s| s.0).sum::<f64>() / n, v.iter().map(|s| s.1).sum::<f64>() / n);
|
||||
let (mut num, mut den) = (0.0, 0.0);
|
||||
for (t, c) in &v {
|
||||
num += (t - mt) * (c - mc);
|
||||
den += (t - mt) * (t - mt);
|
||||
}
|
||||
if den <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let span = v.iter().map(|s| s.0).fold(f64::MIN, f64::max) - v.iter().map(|s| s.0).fold(f64::MAX, f64::min);
|
||||
num / den * (-(span / 2.0) / COOL_TAU_S).exp()
|
||||
}
|
||||
|
||||
/// A typed reading taken while the cards have rested teaches the idle offset (card minus room, 0 to 20 degrees).
|
||||
pub fn learned_offset(card_c: f64, rest_for_s: f64, typed_c: f64) -> Option<f64> {
|
||||
if card_c <= 0.0 || rest_for_s < SETTLE_S {
|
||||
return None;
|
||||
}
|
||||
Some((card_c - typed_c).clamp(0.0, 20.0))
|
||||
}
|
||||
|
||||
/// The loop's memory between ticks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Controller {
|
||||
/// the steady-state share of heat the room needs, 0 to 1 (starts at a half)
|
||||
pub integral: f64,
|
||||
pub period_start: f64,
|
||||
/// this period's duty, 0 to 1
|
||||
pub duty: f64,
|
||||
/// this period's heating slice, seconds
|
||||
pub on_s: f64,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
/// What the engine does this tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Decision {
|
||||
pub heating: bool,
|
||||
pub duty: f64,
|
||||
pub set_c: f64,
|
||||
pub reading: Reading,
|
||||
/// seconds until the slice changes (heating to rest, or the next period)
|
||||
pub until_s: f64,
|
||||
/// a new period began on this tick
|
||||
pub new_period: bool,
|
||||
}
|
||||
|
||||
impl Default for Controller {
|
||||
fn default() -> Controller {
|
||||
Controller::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
pub fn new() -> Controller {
|
||||
Controller { integral: 0.5, period_start: 0.0, duty: 0.0, on_s: 0.0, started: false }
|
||||
}
|
||||
|
||||
/// The duty a reading asks for, and the integral it leaves: the proportional term on the error, the integral on
|
||||
/// the steady-state share; with no reading the find share and an untouched integral.
|
||||
pub fn duty_for(&mut self, set_c: f64, reading: &Reading) -> f64 {
|
||||
match reading.source {
|
||||
Source::None => FIND_DUTY,
|
||||
src => {
|
||||
let err = set_c - reading.room_c;
|
||||
self.integral = (self.integral + KI * err).clamp(0.0, 1.0);
|
||||
let d = (KP * err + self.integral).clamp(0.0, 1.0);
|
||||
if src == Source::Card { d.min(CARD_DUTY_MAX) } else { d }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One tick. A new period is decided on the reading at its start; inside a period only the clock moves.
|
||||
pub fn step(&mut self, now: f64, set_c: f64, reading: Reading) -> Decision {
|
||||
let mut new_period = false;
|
||||
if !self.started || now >= self.period_start + PERIOD_S {
|
||||
new_period = true;
|
||||
self.started = true;
|
||||
self.period_start = now;
|
||||
self.duty = self.duty_for(set_c, &reading);
|
||||
self.on_s = if self.duty >= 0.999 { PERIOD_S } else if self.duty <= 0.001 { 0.0 } else { (self.duty * PERIOD_S).round() };
|
||||
}
|
||||
let heating = now < self.period_start + self.on_s;
|
||||
let until_s = if heating { self.period_start + self.on_s - now } else { self.period_start + PERIOD_S - now };
|
||||
Decision { heating, duty: self.duty, set_c, reading, until_s: until_s.max(0.0), new_period }
|
||||
}
|
||||
|
||||
/// Heat mode switched off or on again: the next tick starts a fresh period; the learned share stays.
|
||||
pub fn reset(&mut self) {
|
||||
self.started = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The `HEAT` log line the gate tool reads (tools/heat-gate.mjs): one every LOG_EVERY_S while heat mode is on.
|
||||
pub fn log_line(unix: f64, phase: &str, set_c: f64, reading: &Reading, duty: f64, heat_w: f64, hash_mhs: f64, until_s: f64) -> String {
|
||||
format!(
|
||||
"HEAT t={} phase={} set={:.1} room={} src={} duty={:.2} heat_w={:.0} hash={:.1} until={:.0}",
|
||||
unix as u64,
|
||||
phase,
|
||||
set_c,
|
||||
if reading.source == Source::None { "-".to_string() } else { format!("{:.1}", reading.room_c) },
|
||||
reading.source.name(),
|
||||
duty,
|
||||
heat_w,
|
||||
hash_mhs,
|
||||
until_s
|
||||
)
|
||||
}
|
||||
|
||||
/// The one line under the switch: what the loop is doing, in the miner's words.
|
||||
pub fn words(on: bool, phase: &str, set_c: f64, reading: &Reading, duty: f64, until_s: f64) -> String {
|
||||
if !on {
|
||||
return "Off. The cards mine whenever the node is synced.".into();
|
||||
}
|
||||
let room = match reading.source {
|
||||
Source::Typed => format!("room {:.1} °C from your reading {}", reading.room_c, if reading.age_s < 90.0 { "just now".to_string() } else { format!("{} min ago", (reading.age_s / 60.0).round() as u64) }),
|
||||
Source::Card => format!("room about {:.0} °C from the card's sensor (within {:.0} degrees)", reading.room_c, reading.error_c),
|
||||
Source::None => "no room reading yet: type one, or the card reads it after 2 minutes of rest".into(),
|
||||
};
|
||||
let doing = match phase {
|
||||
"heating" => format!("heating, {} to go", mins(until_s)),
|
||||
"resting" => format!("resting, heats again in {}", mins(until_s)),
|
||||
"paused" => "mining is paused, so nothing heats".into(),
|
||||
"waiting" => "waiting for the node".into(),
|
||||
_ => phase.to_string(),
|
||||
};
|
||||
format!("Holding {:.1} °C: {}. {}. Heat {:.0}% of the time.", set_c, room, doing, duty * 100.0)
|
||||
}
|
||||
|
||||
fn mins(s: f64) -> String {
|
||||
let m = (s / 60.0).round() as u64;
|
||||
if s < 45.0 { "under a minute".into() } else if m <= 1 { "a minute".into() } else { format!("{m} min") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A room as a first-order store: C joules per degree, K watts per degree of loss to the outside, P_full watts
|
||||
/// from the cards while they heat. The card's die sits offset_c above the room at rest and rise_c more while
|
||||
/// mining, settling with a one-minute time constant.
|
||||
struct Sim {
|
||||
room_c: f64,
|
||||
out_c: f64,
|
||||
c_j_per_k: f64,
|
||||
k_w_per_k: f64,
|
||||
p_full_w: f64,
|
||||
card_rise_c: f64,
|
||||
offset_c: f64,
|
||||
rise_now: f64,
|
||||
}
|
||||
|
||||
impl Sim {
|
||||
fn new(room_c: f64) -> Sim {
|
||||
Sim { room_c, out_c: 10.0, c_j_per_k: 400_000.0, k_w_per_k: 20.0, p_full_w: 300.0, card_rise_c: 40.0, offset_c: 8.0, rise_now: 0.0 }
|
||||
}
|
||||
fn tick(&mut self, heating: bool, dt: f64) {
|
||||
let p = if heating { self.p_full_w } else { 0.0 };
|
||||
self.room_c += (p - self.k_w_per_k * (self.room_c - self.out_c)) * dt / self.c_j_per_k;
|
||||
let target = if heating { self.card_rise_c } else { 0.0 };
|
||||
self.rise_now += (target - self.rise_now) * (1.0 - (-dt / 60.0).exp());
|
||||
}
|
||||
fn card_c(&self) -> f64 {
|
||||
self.room_c + self.offset_c + self.rise_now
|
||||
}
|
||||
}
|
||||
|
||||
/// Four hours after the first hour, the room stays within a degree of the set point and the hash is on exactly
|
||||
/// while the slice heats: the gate's shape (docs/plans/ember-heat.md), on a model room with the typed reading
|
||||
/// refreshed every half hour, as a miner with a thermometer on the desk would do.
|
||||
#[test]
|
||||
fn a_typed_reading_holds_the_room_within_a_degree_for_four_hours() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
let mut ctl = Controller::new();
|
||||
let set = 20.0;
|
||||
let t0 = 1000.0;
|
||||
let mut t = t0;
|
||||
let mut typed = (sim.room_c, t0);
|
||||
let mut worst: f64 = 0.0;
|
||||
let mut heating_s = 0.0;
|
||||
let mut hash_on_s = 0.0;
|
||||
while t < t0 + 5.0 * 3600.0 {
|
||||
if t - typed.1 >= 1800.0 {
|
||||
typed = (sim.room_c, t);
|
||||
}
|
||||
let r = room(t, Some(typed), sim.card_c(), 0.0, 0.0, 0.0);
|
||||
assert_eq!(r.source, Source::Typed);
|
||||
let d = ctl.step(t, set, r);
|
||||
let hash = if d.heating { 100.0 } else { 0.0 };
|
||||
sim.tick(d.heating, 1.0);
|
||||
if t >= t0 + 3600.0 {
|
||||
worst = worst.max((sim.room_c - set).abs());
|
||||
if d.heating { heating_s += 1.0; }
|
||||
if hash > 0.0 { hash_on_s += 1.0; }
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
|
||||
assert_eq!(heating_s, hash_on_s, "the hash was on exactly while the slice heated");
|
||||
// the room needs 20 W per degree over 10 degrees = 200 W of 300: a duty near two thirds
|
||||
assert!((ctl.integral - 0.667).abs() < 0.15, "the integral found the steady share: {:.2}", ctl.integral);
|
||||
assert!(heating_s / (4.0 * 3600.0) > 0.5 && heating_s / (4.0 * 3600.0) < 0.85, "the hash followed the duty: {:.2}", heating_s / (4.0 * 3600.0));
|
||||
}
|
||||
|
||||
/// The card's own sensor as the only source: every period keeps three minutes of rest, the reading is taken after
|
||||
/// that rest with the cooling tail taken off, and the room still holds within a degree over the four hours after
|
||||
/// the first. The sensor's slope comes from the last half minute of samples, as the engine takes it.
|
||||
#[test]
|
||||
fn the_card_sensor_alone_holds_the_room_within_a_degree() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
let mut ctl = Controller::new();
|
||||
let set = 20.0;
|
||||
let mut t = 0.0;
|
||||
let mut rest_since: Option<f64> = None;
|
||||
let mut worst: f64 = 0.0;
|
||||
let mut estimate_err: f64 = 0.0;
|
||||
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
|
||||
while t < 5.0 * 3600.0 {
|
||||
let rest_for = rest_since.map(|s| t - s).unwrap_or(0.0);
|
||||
samples.push_back((t, sim.card_c()));
|
||||
while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); }
|
||||
let slope = slope_of(samples.iter().copied());
|
||||
let r = room(t, None, sim.card_c(), slope, rest_for, 0.0);
|
||||
let d = ctl.step(t, set, r);
|
||||
if d.new_period {
|
||||
assert!(d.duty <= CARD_DUTY_MAX + 1e-9, "a card-sensed period keeps its rest: {}", d.duty);
|
||||
if r.source == Source::Card {
|
||||
estimate_err = estimate_err.max((r.room_c - sim.room_c).abs());
|
||||
}
|
||||
}
|
||||
sim.tick(d.heating, 1.0);
|
||||
rest_since = if d.heating { None } else { Some(rest_since.unwrap_or(t)) };
|
||||
if t >= 3600.0 {
|
||||
worst = worst.max((sim.room_c - set).abs());
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
|
||||
assert!(estimate_err < OFFSET_ERROR_C, "the card estimate stayed inside its stated error: {estimate_err:.2}");
|
||||
}
|
||||
|
||||
/// Without the slope correction the sensor still carries the cooling tail at the end of the rest and the
|
||||
/// estimate reads high by more than the stated error: the correction is what makes the card path honest.
|
||||
#[test]
|
||||
fn the_cooling_tail_is_taken_off_by_the_slope() {
|
||||
let mut sim = Sim::new(19.0);
|
||||
for _ in 0..600 { sim.tick(true, 1.0); }
|
||||
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
|
||||
let mut t = 0.0;
|
||||
for _ in 0..(SETTLE_S as usize) { sim.tick(false, 1.0); t += 1.0; samples.push_back((t, sim.card_c())); while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); } }
|
||||
let slope = slope_of(samples.iter().copied());
|
||||
let raw = room(t, None, sim.card_c(), 0.0, SETTLE_S, 8.0);
|
||||
let fixed = room(t, None, sim.card_c(), slope, SETTLE_S, 8.0);
|
||||
assert!(raw.room_c - sim.room_c > 1.0, "the raw sensor still reads the tail: {:.2} over", raw.room_c - sim.room_c);
|
||||
assert!((fixed.room_c - sim.room_c).abs() < 0.5, "the corrected estimate is the room: {:.2} off", fixed.room_c - sim.room_c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_reading_the_loop_heats_the_find_share_and_says_so() {
|
||||
let mut ctl = Controller::new();
|
||||
let d = ctl.step(1000.0, 20.0, Reading::none());
|
||||
assert_eq!(d.duty, FIND_DUTY);
|
||||
assert!(d.heating);
|
||||
assert_eq!(ctl.integral, 0.5, "no reading leaves the integral alone");
|
||||
assert!(words(true, "heating", 20.0, &d.reading, d.duty, d.until_s).contains("no room reading yet"));
|
||||
// the slice ends at 80% of the period, the rest runs to the period's end
|
||||
let d2 = ctl.step(1000.0 + FIND_DUTY * PERIOD_S + 1.0, 20.0, Reading::none());
|
||||
assert!(!d2.heating);
|
||||
assert!(!d2.new_period);
|
||||
assert!(d2.until_s <= (1.0 - FIND_DUTY) * PERIOD_S);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_room_source_order_is_typed_then_card_then_none() {
|
||||
let typed = Some((19.5, 1000.0));
|
||||
assert_eq!(room(1500.0, typed, 30.0, 0.0, 300.0, 0.0).source, Source::Typed);
|
||||
let stale = room(1000.0 + TYPED_FRESH_S + 1.0, typed, 30.0, 0.0, 300.0, 0.0);
|
||||
assert_eq!(stale.source, Source::Card);
|
||||
assert!((stale.room_c - 22.0).abs() < 1e-9, "card 30 minus the default offset 8");
|
||||
assert_eq!(room(1500.0, None, 30.0, 0.0, 60.0, 0.0).source, Source::None, "a card still warm from mining is not the room");
|
||||
assert_eq!(room(1500.0, None, 0.0, 0.0, 600.0, 0.0).source, Source::None, "no card sensor at all");
|
||||
assert!((room(1500.0, None, 30.0, 0.02, 300.0, 0.0).room_c - 22.0).abs() < 1e-9, "a rising sensor is not corrected");
|
||||
let learned = room(1500.0, None, 30.0, 0.0, 300.0, 11.0);
|
||||
assert!((learned.room_c - 19.0).abs() < 1e-9, "a learned offset replaces the default");
|
||||
assert_eq!(learned_offset(30.0, 300.0, 19.0), Some(11.0));
|
||||
assert_eq!(learned_offset(30.0, 30.0, 19.0), None, "a card that has not rested teaches nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_parses_sorts_and_wraps_at_midnight() {
|
||||
let s = parse_schedule("22:00 16, 06:00 20").unwrap();
|
||||
assert_eq!(s, vec![Slot { minute: 360, set_c: 20.0 }, Slot { minute: 1320, set_c: 16.0 }]);
|
||||
assert_eq!(schedule_text(&s), "06:00 20, 22:00 16");
|
||||
assert_eq!(set_point_at(19.0, &s, 7 * 60), 20.0);
|
||||
assert_eq!(set_point_at(19.0, &s, 23 * 60), 16.0);
|
||||
assert_eq!(set_point_at(19.0, &s, 2 * 60), 16.0, "before the first slot the last one of the day holds");
|
||||
assert_eq!(set_point_at(19.0, &[], 2 * 60), 19.0);
|
||||
assert!(parse_schedule("06:00").is_err());
|
||||
assert!(parse_schedule("25:00 20").is_err());
|
||||
assert!(parse_schedule("06:00 40").is_err(), "the set point is 5 to 30");
|
||||
assert_eq!(parse_schedule("").unwrap(), vec![]);
|
||||
assert_eq!(minute_of_day(0.0, 60), 60, "one hour east of UTC at midnight UTC is 01:00");
|
||||
assert_eq!(minute_of_day(0.0, -300), 19 * 60, "five hours west wraps to the evening before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_line_carries_what_the_gate_reads() {
|
||||
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 30.0 };
|
||||
let l = log_line(1_759_800_000.0, "heating", 20.0, &r, 0.6, 180.0, 74.2, 312.0);
|
||||
assert_eq!(l, "HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312");
|
||||
let l2 = log_line(1.0, "resting", 20.0, &Reading::none(), 0.8, 0.0, 0.0, 10.0);
|
||||
assert!(l2.contains("room=- src=none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_words_name_the_source_the_slice_and_the_share() {
|
||||
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 2400.0 };
|
||||
assert_eq!(words(true, "heating", 20.0, &r, 0.6, 312.0), "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.");
|
||||
let c = Reading { room_c: 19.0, source: Source::Card, error_c: 3.0, age_s: 0.0 };
|
||||
assert_eq!(words(true, "resting", 20.0, &c, 0.5, 100.0), "Holding 20.0 °C: room about 19 °C from the card's sensor (within 3 degrees). resting, heats again in 2 min. Heat 50% of the time.");
|
||||
assert!(words(false, "off", 20.0, &c, 0.0, 0.0).starts_with("Off."));
|
||||
}
|
||||
}
|
||||
|
|
@ -167,6 +167,10 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
|||
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
|
||||
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
||||
c.tune_source = p.sweep_source.clone();
|
||||
c.tune_goal = p.tune_goal.clone();
|
||||
c.tune_before_watts = p.sweep_before_watts;
|
||||
c.tune_before_mhs = p.sweep_before_mhs;
|
||||
c.tune_floor = p.sweep_floor;
|
||||
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
||||
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ pub enum Action {
|
|||
StopMiners(String),
|
||||
RestartMiners,
|
||||
RestartNode,
|
||||
/// The signed `cards` kind: apply these per-card choices through the app's own card path (persisted), then
|
||||
/// call `cards_applied` with the read-back.
|
||||
ApplyCards(Vec<crate::engine::CardChoice>),
|
||||
/// The relaunch helper was started; the engine quits now.
|
||||
RestartApp,
|
||||
UpdateNow,
|
||||
|
|
@ -137,6 +140,12 @@ pub fn key_without_index(k: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// The restore list of a `cards_leave_off` job: the same entries (identities and cap kept) with enabled=false, so the
|
||||
/// runner's restore on any exit leaves the card off, persisted by the app's own card path.
|
||||
pub fn leave_off(restore: Vec<crate::engine::CardChoice>) -> Vec<crate::engine::CardChoice> {
|
||||
restore.into_iter().map(|mut c| { c.enabled = false; c }).collect()
|
||||
}
|
||||
|
||||
/// One live card as `cards_off_choices` sees it: key, enabled, identities, power_pct, present.
|
||||
pub type LiveCard = (String, bool, u32, u32, bool);
|
||||
|
||||
|
|
@ -239,11 +248,15 @@ impl Jobs {
|
|||
active: None,
|
||||
needs_logged: std::collections::HashSet::new(),
|
||||
fingerprint: manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX),
|
||||
wake: Arc::new(WakeCtl { on: AtomicBool::new(allowed) }),
|
||||
wake: Arc::new(WakeCtl::new(allowed, &shared.runtime.id8())),
|
||||
wake_pending: false,
|
||||
last_published: String::new(),
|
||||
};
|
||||
j.publish(shared);
|
||||
// the ping names the last job this machine ran, across restarts (the newest record in jobs-state.json)
|
||||
if let Some(last) = j.ledger.records.values().max_by_key(|r| r.started_at) {
|
||||
j.wake.set_last_job(&last.id);
|
||||
}
|
||||
if !j.url.is_empty() {
|
||||
let wake_url = wake_url_of(std::env::var("IGNEUM_APP_JOBS_WAKE_URL").ok());
|
||||
if !wake_url.is_empty() {
|
||||
|
|
@ -559,11 +572,29 @@ impl Jobs {
|
|||
return None;
|
||||
}
|
||||
shared.event("info", &format!("job {} ({}) starts: {}", job.id, job.kind, job.label()));
|
||||
self.wake.set_last_job(&job.id);
|
||||
let ctl = Arc::new(Ctl::default());
|
||||
let needs_miners_stopped = job.kind == "shard-benchmark" || (job.kind == "run" && job.bool_param("stop_miners_first"));
|
||||
let cards_off: Vec<String> = if job.kind == "run" { job.list_param("cards_off") } else { vec![] };
|
||||
self.active = Some(Active { job: job.clone(), run_id: run_id.clone(), started: Instant::now(), started_unix: now, waiting_for_miners: needs_miners_stopped, holds_miners: false, waiting_for_cards: !cards_off.is_empty(), cards: CardHold::default(), ctl: ctl.clone() });
|
||||
match job.kind.as_str() {
|
||||
"cards" => {
|
||||
// engine-side: refused at once for a card this machine does not have; else applied through the
|
||||
// app's own card path and reported with the read-back (cards_applied)
|
||||
let live: Vec<(String, bool, u32)> = shared.state.lock().unwrap().mining.cards.iter().filter(|c| c.present()).map(|c| (c.key.clone(), c.enabled, c.identities)).collect();
|
||||
let (choices, missing) = cards_job_choices(&job, &live);
|
||||
let sink = Sink::new(shared, &job, &self.dir);
|
||||
if !missing.is_empty() {
|
||||
let summary = format!("refused: this machine has no card {}", missing.join(", "));
|
||||
sink.line(&format!("cards: {summary}; present: {}", live.iter().map(|c| c.0.as_str()).collect::<Vec<_>>().join(", ")));
|
||||
let uploaded = report(shared, &job, &sink, "failed", 2, now, crate::platform::unix_now(), &summary, json!({ "present": live.iter().map(|c| c.0.clone()).collect::<Vec<_>>() }));
|
||||
let o = Outcome { status: "failed".into(), exit: 2, summary, results: vec![], uploaded };
|
||||
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o });
|
||||
return None;
|
||||
}
|
||||
sink.line(&format!("cards: applying {}", choices.iter().map(|c| format!("{} enabled={} identities={}{}", c.key, c.enabled, c.identities, c.power_pct.map(|p| format!(" power_pct={p}")).unwrap_or_default())).collect::<Vec<_>>().join("; ")));
|
||||
return Some(Action::ApplyCards(choices));
|
||||
}
|
||||
"restart" | "update-now" => {
|
||||
// engine-side; the report says what was asked and the ledger closes at once
|
||||
let what = job.str_param("what");
|
||||
|
|
@ -597,6 +628,35 @@ impl Jobs {
|
|||
}
|
||||
}
|
||||
|
||||
/// The running job's id, for the hold rule (a hold belongs to the job that took it).
|
||||
pub fn active_id(&self) -> Option<String> {
|
||||
self.active.as_ref().map(|a| a.job.id.clone())
|
||||
}
|
||||
|
||||
/// The running job's cap in minutes (the hold's hard cap).
|
||||
pub fn active_cap_minutes(&self) -> u64 {
|
||||
self.active.as_ref().map(|a| a.job.timeout_minutes()).unwrap_or(60)
|
||||
}
|
||||
|
||||
/// Called by the engine once a `cards` job's choices are applied: the report carries the read-back of every
|
||||
/// card named (what the engine holds now) and the job closes done.
|
||||
pub fn cards_applied(&mut self, shared: &Arc<Shared>, readback: Vec<(String, bool, u32, u32)>) -> Option<Action> {
|
||||
let Some(a) = self.active.as_ref() else { return None };
|
||||
if a.job.kind != "cards" {
|
||||
return None;
|
||||
}
|
||||
let (job, started) = (a.job.clone(), a.started_unix);
|
||||
let sink = Sink::new(shared, &job, &self.dir);
|
||||
let lines: Vec<String> = readback.iter().map(|(k, e, i, p)| format!("{k} enabled={e} identities={i} power_pct={p}")).collect();
|
||||
for l in &lines {
|
||||
sink.line(&format!("cards: read back {l}"));
|
||||
}
|
||||
let summary = format!("cards applied: {}", lines.join("; "));
|
||||
let uploaded = report(shared, &job, &sink, "done", 0, started, crate::platform::unix_now(), &summary, json!({ "cards": readback.iter().map(|(k, e, i, p)| json!({ "key": k, "enabled": e, "identities": i, "power_pct": p })).collect::<Vec<_>>() }));
|
||||
let o = Outcome { status: "done".into(), exit: 0, summary, results: lines, uploaded };
|
||||
self.event(shared, Event::Finished { id: job.id.clone(), outcome: o })
|
||||
}
|
||||
|
||||
/// Called by the engine once a job's `--cards-off` cards are switched off; `restore` puts them back exactly.
|
||||
/// Next: the miners, if the job asked for them too, else the script.
|
||||
pub fn cards_off_done(&mut self, shared: &Arc<Shared>, restore: Vec<crate::engine::CardChoice>) -> Option<Action> {
|
||||
|
|
@ -605,6 +665,10 @@ impl Jobs {
|
|||
return None;
|
||||
}
|
||||
a.waiting_for_cards = false;
|
||||
// cards_leave_off (7 October 2026, intel-arc): the hold still ends through the app's own card path on any
|
||||
// exit, but with enabled=false, so the card stays off and persisted (settings.json) after the job: the way
|
||||
// to hold a card out of mining past a job without a script touching api/cards (the 6 October rule).
|
||||
let restore = if a.job.bool_param("cards_leave_off") { leave_off(restore) } else { restore };
|
||||
a.cards = CardHold::hold(restore);
|
||||
if a.waiting_for_miners {
|
||||
return Some(Action::StopMiners(format!("job {}: {}", a.job.id, a.job.label())));
|
||||
|
|
@ -630,7 +694,11 @@ impl Jobs {
|
|||
if held.is_empty() {
|
||||
sink.line(&format!("cards-off: {} asked, no present card matched (nothing switched; the script's card may be loaded)", asked.join(",")));
|
||||
} else {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
|
||||
if job.bool_param("cards_leave_off") {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards LEFT OFF: {} by the runner on any exit (done, failed, timeout, aborted, app quit), enabled=false with their own identities and cap, persisted by the app (cards_leave_off)", held.keys(), held.keys()));
|
||||
} else {
|
||||
sink.line(&format!("cards-off: {} switched off by the runner before this script; cards restored: {} by the runner on any exit (done, failed, timeout, aborted, app quit), with their own enabled flag, identities and cap", held.keys(), held.keys()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ctx = account_context();
|
||||
|
|
@ -764,9 +832,43 @@ fn upload_file(shared: &Arc<Shared>, job: &Job, path: &Path, label_prefix: &str)
|
|||
// busy-loops: a reply that came back early is followed by the rest of a 10 s floor, a failing endpoint backs off
|
||||
// 5, 15, then 60 s, and an empty stamp (nothing published yet) waits a full hold.
|
||||
|
||||
/// Shared with the waker thread: it polls only while remote jobs are allowed.
|
||||
/// Shared with the waker thread: it polls only while remote jobs are allowed. The ping (MF-11, 0.3.21): every wake
|
||||
/// request names this machine (its id8, no secret), the app version and the last job it ran, so the relay can show a
|
||||
/// machine whose job channel has gone quiet ("silent since <time>, last job <name>") the moment 15 minutes pass with no
|
||||
/// poll; a dead app polls nothing, and before this nothing on the console said so until the next upload gap was noticed.
|
||||
pub struct WakeCtl {
|
||||
on: AtomicBool,
|
||||
machine: String,
|
||||
last_job: Mutex<String>,
|
||||
}
|
||||
|
||||
impl WakeCtl {
|
||||
pub fn new(on: bool, machine: &str) -> WakeCtl {
|
||||
WakeCtl { on: AtomicBool::new(on), machine: machine.to_string(), last_job: Mutex::new(String::new()) }
|
||||
}
|
||||
pub fn set_last_job(&self, id: &str) {
|
||||
*self.last_job.lock().unwrap() = id.to_string();
|
||||
}
|
||||
/// The query fragment of the ping: machine=<id8>&v=<version>[&job=<id>]; values are the app's own, URL-safe by shape.
|
||||
pub fn ping(&self) -> String {
|
||||
ping_query(&self.machine, crate::engine::VERSION, &self.last_job.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// machine and job ids as the relay reads them: id8 is 8 hex; a job id is the publisher's `[\w.-]` name; anything else
|
||||
/// is dropped from the query rather than escaped (the relay clips and validates on its side too).
|
||||
pub fn ping_query(machine: &str, version: &str, last_job: &str) -> String {
|
||||
let safe = |s: &str, max: usize| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':')).take(max).collect() };
|
||||
let m = safe(machine, 16);
|
||||
if m.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut q = format!("machine={m}&v={}", safe(version, 32));
|
||||
let j = safe(last_job, 80);
|
||||
if !j.is_empty() {
|
||||
q.push_str(&format!("&job={j}"));
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// The stamp logic, free of I/O for the tests.
|
||||
|
|
@ -829,17 +931,24 @@ fn wake_url_of(env: Option<String>) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn wake_query(url: &str, since: &str) -> String {
|
||||
if since.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{url}{}since={since}", if url.contains('?') { '&' } else { '?' })
|
||||
fn wake_query(url: &str, since: &str, ping: &str) -> String {
|
||||
let mut out = url.to_string();
|
||||
let mut sep = if url.contains('?') { '&' } else { '?' };
|
||||
if !since.is_empty() {
|
||||
out.push(sep);
|
||||
out.push_str(&format!("since={since}"));
|
||||
sep = '&';
|
||||
}
|
||||
if !ping.is_empty() {
|
||||
out.push(sep);
|
||||
out.push_str(ping);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One long-poll. Ok carries the relay's stamp (empty when it holds none).
|
||||
fn wake_request(url: &str, since: &str) -> Result<String, String> {
|
||||
let full = wake_query(url, since);
|
||||
fn wake_request(url: &str, since: &str, ping: &str) -> Result<String, String> {
|
||||
let full = wake_query(url, since, ping);
|
||||
let max_time = (WAKE_HOLD_S + 13).to_string();
|
||||
let (code, out) = run_capture(Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", &max_time, &full]), Duration::from_secs(WAKE_HOLD_S + 20));
|
||||
if code != Some(0) {
|
||||
|
|
@ -860,7 +969,7 @@ fn wake_loop(shared: Arc<Shared>, url: String, ctl: Arc<WakeCtl>) {
|
|||
continue;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
match wake_request(&url, &st.stamp) {
|
||||
match wake_request(&url, &st.stamp, &ctl.ping()) {
|
||||
Ok(stamp) => {
|
||||
let (r, recovered) = st.reply(&stamp);
|
||||
if recovered {
|
||||
|
|
@ -1316,6 +1425,50 @@ fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// The choices a `cards` job asks for, matched to the machine's present cards (key exact, or the key with the device
|
||||
/// index left out: `vendor::name`); missing keys are returned for the refusal. A field the job leaves out keeps the
|
||||
/// card's current value.
|
||||
pub fn cards_job_choices(job: &Job, live: &[(String, bool, u32)]) -> (Vec<crate::engine::CardChoice>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
for c in list {
|
||||
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
|
||||
let found = live.iter().find(|(k, _, _)| *k == key).or_else(|| {
|
||||
let parts: Vec<&str> = key.splitn(3, ':').collect();
|
||||
live.iter().find(|(k, _, _)| { let lp: Vec<&str> = k.splitn(3, ':').collect(); parts.len() == 3 && lp.len() == 3 && lp[0] == parts[0] && lp[2] == parts[2] && (parts[1].is_empty() || parts[1] == lp[1]) })
|
||||
});
|
||||
match found {
|
||||
Some((k, enabled, identities)) => out.push(crate::engine::CardChoice {
|
||||
key: k.clone(),
|
||||
enabled: c.get("enabled").and_then(|v| v.as_bool()).unwrap_or(*enabled),
|
||||
identities: c.get("identities").and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(*identities),
|
||||
power_pct: c.get("power_pct").and_then(|v| v.as_u64()).map(|n| n as u32),
|
||||
}),
|
||||
None => missing.push(key),
|
||||
}
|
||||
}
|
||||
(out, missing)
|
||||
}
|
||||
|
||||
/// The hold rule (MF-6, PC 1, 7 October 2026: a read-only job that followed a --stop-miners job kept the cards off
|
||||
/// for its whole run): a hold belongs to the job that took it and releases the moment that job is no longer the
|
||||
/// running one, whatever runs next, or when the owner's own cap has passed. Returns the reason to release, or None.
|
||||
pub fn hold_release(owner: Option<&str>, active: Option<&str>, held_s: f64, cap_s: f64) -> Option<&'static str> {
|
||||
match owner {
|
||||
None => Some("no job owns the hold"),
|
||||
Some(o) => {
|
||||
if active != Some(o) {
|
||||
Some("the job that took the hold is no longer running")
|
||||
} else if held_s >= cap_s {
|
||||
Some("the hold passed the job's own cap")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
|
||||
match ran.code {
|
||||
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
|
||||
|
|
@ -1434,6 +1587,23 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// cards_leave_off (7 October 2026): the restore entries keep their identities and cap and carry enabled=false,
|
||||
/// so the job's exit leaves the card off through the same path; without the param they keep their own flag.
|
||||
#[test]
|
||||
fn cards_leave_off_restores_the_card_as_off_with_its_settings_kept() {
|
||||
let live: Vec<LiveCard> = vec![("other:Intel(R) Arc(TM) B580 Graphics".into(), true, 8, 0, true), ("nvidia:NVIDIA GeForce RTX 5090".into(), true, 2, 80, true)];
|
||||
let (off, restore) = cards_off_choices(&["other:Intel(R) Arc(TM) B580 Graphics".to_string()], &live);
|
||||
assert_eq!(off.len(), 1);
|
||||
assert!(restore[0].enabled, "the plain restore puts the card back on");
|
||||
let left = leave_off(restore.clone());
|
||||
assert_eq!(left.len(), 1);
|
||||
assert_eq!((left[0].key.as_str(), left[0].enabled, left[0].identities, left[0].power_pct), ("other:Intel(R) Arc(TM) B580 Graphics", false, 8, restore[0].power_pct));
|
||||
// the job's param decides, through the same hold
|
||||
let j = jobs::parse(r#"{"jobs":[{"id":"x","kind":"run","expires_at":"2099-01-01T00:00:00Z","target":{"machine_ids":["ae432dc7"]},"params":{"script":"ls","cards_off":["other:Intel(R) Arc(TM) B580 Graphics"],"cards_leave_off":true}}]}"#).unwrap().jobs.remove(0);
|
||||
assert!(j.bool_param("cards_leave_off"));
|
||||
assert_eq!(j.list_param("cards_off"), vec!["other:Intel(R) Arc(TM) B580 Graphics".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_off_matches_keys_with_and_without_the_device_index_and_restores_exactly() {
|
||||
// PC 1, 6 October 2026: settings.json holds amd:1:gfx1201 and amd:3:gfx1201, the live state's key is amd:gfx1201
|
||||
|
|
@ -1566,9 +1736,16 @@ mod tests {
|
|||
assert_eq!(wake_url_of(None), WAKE_URL);
|
||||
assert_eq!(wake_url_of(Some(String::new())), "");
|
||||
assert_eq!(wake_url_of(Some(" http://127.0.0.1:4180/wake ".into())), "http://127.0.0.1:4180/wake");
|
||||
assert_eq!(wake_query("https://r/wake", ""), "https://r/wake");
|
||||
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5"), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
|
||||
assert_eq!(wake_query("https://r/api/wake?x=1", "S"), "https://r/api/wake?x=1&since=S");
|
||||
assert_eq!(wake_query("https://r/wake", "", ""), "https://r/wake");
|
||||
assert_eq!(wake_query("https://r/wake", "2026-10-05T11:02:17Z.5e7b56f5", ""), "https://r/wake?since=2026-10-05T11:02:17Z.5e7b56f5");
|
||||
assert_eq!(wake_query("https://r/api/wake?x=1", "S", ""), "https://r/api/wake?x=1&since=S");
|
||||
// the ping (MF-11): the machine, the version and the last job ride on every wake request
|
||||
assert_eq!(wake_query("https://r/wake", "", "machine=1ccfe586&v=0.3.21"), "https://r/wake?machine=1ccfe586&v=0.3.21");
|
||||
assert_eq!(wake_query("https://r/wake", "S", "machine=1ccfe586&v=0.3.21&job=update-now-0319"), "https://r/wake?since=S&machine=1ccfe586&v=0.3.21&job=update-now-0319");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", ""), "machine=1ccfe586&v=0.3.21");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", "update-now-0319-1ccfe586"), "machine=1ccfe586&v=0.3.21&job=update-now-0319-1ccfe586");
|
||||
assert_eq!(ping_query("", "0.3.21", "x"), "", "no machine, no ping");
|
||||
assert_eq!(ping_query("1ccfe586", "0.3.21", "a b&c=d"), "machine=1ccfe586&v=0.3.21&job=abcd", "only the safe characters travel");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2058,3 +2235,23 @@ fn account_warning(ctx: &str) -> String {
|
|||
fn short(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::<String>()) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hold_tests {
|
||||
use super::hold_release;
|
||||
|
||||
/// MF-6 (PC 1, 7 October 2026): a --stop-miners job's hold outlived it into a read-only watch job for three minutes.
|
||||
#[test]
|
||||
fn a_hold_belongs_to_the_job_that_took_it() {
|
||||
// the owner is still running, under its cap: the hold stays
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 30.0, 3600.0), None);
|
||||
// another job runs now: released at once, whatever that job is
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-b"), 30.0, 3600.0), Some("the job that took the hold is no longer running"));
|
||||
// no job runs: released
|
||||
assert_eq!(hold_release(Some("job-a"), None, 30.0, 3600.0), Some("the job that took the hold is no longer running"));
|
||||
// the owner's own cap passed: released and logged
|
||||
assert_eq!(hold_release(Some("job-a"), Some("job-a"), 3601.0, 3600.0), Some("the hold passed the job's own cap"));
|
||||
// a hold with no owner (an older engine state) never sticks
|
||||
assert_eq!(hold_release(None, Some("job-b"), 1.0, 3600.0), Some("no job owns the hold"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
//!
|
||||
//! Kinds and their params:
|
||||
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
|
||||
//! timeout_minutes (default 60, at most 600)
|
||||
//! timeout_minutes (default 60, at most 600), cards_off [keys] (the runner switches them off before
|
||||
//! the script and restores them on any exit), cards_leave_off (with cards_off: restored as OFF, so
|
||||
//! the card stays off and persisted after the job; 7 October 2026, the Arc on PC 1)
|
||||
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
|
||||
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
|
||||
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command
|
||||
|
|
@ -59,7 +61,7 @@ pub const JOBS_FILE: &str = "igneum-jobs.json";
|
|||
/// the signature is over the bytes of `file`, so the same key and the same signer sign both forms.
|
||||
pub const JOBS_SIGNED_FILE: &str = "igneum-jobs.signed.json";
|
||||
pub const JOBS_SIGNED_FORMAT: &str = "igneum-jobs-signed-1";
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build"];
|
||||
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark", "build", "cards"];
|
||||
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
|
||||
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
|
||||
/// Named folders a `fetch` may write into, all under the app data root.
|
||||
|
|
@ -418,6 +420,33 @@ pub fn validate_params(job: &Job) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
"update-now" => {}
|
||||
"cards" => {
|
||||
// the signed `cards` kind (7 October 2026): per-card enabled and identities, applied by the app through
|
||||
// its own card path and persisted; it closes the exception of a one-off script POSTing /api/cards
|
||||
let list = job.params.get("cards").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
if list.is_empty() {
|
||||
return Err("cards: params.cards is empty (a list of {key, enabled, identities})".into());
|
||||
}
|
||||
for c in &list {
|
||||
let key = c.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if key.trim().is_empty() || !key.contains(':') {
|
||||
return Err(format!("cards: key '{key}' is not a card key (vendor:device:name)"));
|
||||
}
|
||||
if c.get("enabled").map(|v| !v.is_boolean()).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: enabled must be true or false"));
|
||||
}
|
||||
if let Some(n) = c.get("identities") {
|
||||
if !n.as_u64().map(|n| (1..=64).contains(&n)).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: identities must be 1 to 64"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = c.get("power_pct") {
|
||||
if !n.as_u64().map(|n| (50..=100).contains(&n)).unwrap_or(false) {
|
||||
return Err(format!("cards: {key}: power_pct must be 50 to 100"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"shard-benchmark" => {
|
||||
let url = job.str_param("zip_url");
|
||||
if !url.is_empty() && !https_ok(&url) {
|
||||
|
|
@ -824,6 +853,10 @@ mod tests {
|
|||
assert!(j("restart", r#"{"what":"everything"}"#).unwrap_err().contains("restart"));
|
||||
assert!(j("restart", r#"{"what":"miners"}"#).is_ok());
|
||||
assert!(j("update-now", r#"{}"#).is_ok());
|
||||
assert!(j("cards", r#"{}"#).unwrap_err().contains("cards"));
|
||||
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:NVIDIA GeForce RTX 5090","enabled":true,"identities":8}]}"#).is_ok());
|
||||
assert!(j("cards", r#"{"cards":[{"key":"5090","enabled":true}]}"#).unwrap_err().contains("card key"));
|
||||
assert!(j("cards", r#"{"cards":[{"key":"nvidia:0:x","identities":65}]}"#).unwrap_err().contains("1 to 64"));
|
||||
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
|
||||
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
|
||||
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
|
||||
|
|
|
|||
735
app/igneum-app/src/ladder.rs
Normal file
735
app/igneum-app/src/ladder.rs
Normal file
|
|
@ -0,0 +1,735 @@
|
|||
//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's
|
||||
//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this
|
||||
//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day
|
||||
//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards
|
||||
//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first
|
||||
//! on a new card). Persisted as `<app dir>/ladder.json`; pure, no clock of its own (every call takes `now`).
|
||||
//!
|
||||
//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints
|
||||
//! `PoW accepted <hash> by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the
|
||||
//! nonce is the join and nothing else is.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const DAY_S: f64 = 86_400.0;
|
||||
/// days kept in the ring (the 30-day window plus a month of slack for the view)
|
||||
const KEEP_DAYS: u64 = 60;
|
||||
const KEEP_BLOCKS: usize = 30;
|
||||
const KEEP_SHARDS: usize = 50;
|
||||
const KEEP_PENDING: usize = 400;
|
||||
const KEEP_SIGNED: usize = 400;
|
||||
/// the block counts that raise a card (then every 10,000th)
|
||||
pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000];
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DayBlocks {
|
||||
/// unix day number (unix seconds / 86,400, UTC)
|
||||
pub day: u64,
|
||||
pub blocks: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BlockFound {
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub nonce: String,
|
||||
pub at: f64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
/// this machine's lifetime count at that block (1 = the first)
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ShardPaid {
|
||||
pub block: u64,
|
||||
pub shard: u64,
|
||||
/// wei as a decimal string (u128 does not survive every JSON reader)
|
||||
pub wei: String,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Milestone {
|
||||
/// first | hundred | thousand | ten_thousand | card
|
||||
pub kind: String,
|
||||
pub count: u64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Ladder {
|
||||
#[serde(default)]
|
||||
pub first_block_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_block_card: String,
|
||||
#[serde(default)]
|
||||
pub first_block_hash: String,
|
||||
#[serde(default)]
|
||||
pub first_block_daa: u64,
|
||||
#[serde(default)]
|
||||
pub days: Vec<DayBlocks>,
|
||||
/// card key -> unix s of its first block
|
||||
#[serde(default)]
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub blocks: Vec<BlockFound>,
|
||||
#[serde(default)]
|
||||
pub votes_signed: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_index: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_at: f64,
|
||||
/// checkpoints signed and not yet seen locked
|
||||
#[serde(default)]
|
||||
pub signed_open: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pub last_signed_locked: u64,
|
||||
#[serde(default)]
|
||||
pub signed_locked_count: u64,
|
||||
/// when the unbroken run of votes began (0 = no vote yet)
|
||||
#[serde(default)]
|
||||
pub streak_since: f64,
|
||||
#[serde(default)]
|
||||
pub shards: Vec<ShardPaid>,
|
||||
#[serde(default)]
|
||||
pub milestone: Option<Milestone>,
|
||||
/// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet)
|
||||
#[serde(default)]
|
||||
pub first_synced_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_mining_at: f64,
|
||||
/// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case
|
||||
#[serde(default)]
|
||||
pending: Vec<(String, String, u64)>,
|
||||
/// first-block-21 (the project lead, 7 October 2026: "the 'you got your first block' situation only shows for the miner's
|
||||
/// first ever block"; main's reading: SEEN once): set when a window has shown the first-block card (the page
|
||||
/// reports it through POST /api/card/seen on display or dismiss), never when it is raised. The card is raised when
|
||||
/// the lifetime count the engine persists (settings.json accepted_total) crosses from 0 to 1 and waits, across
|
||||
/// restarts and updates, until a window shows it: a block found overnight or across an auto-update greets the
|
||||
/// miner the first time they open the app, and after that never again. Kept across restarts, updates and a kept
|
||||
/// data directory; never cleared by a key changing identities or a card being swapped (the count is the
|
||||
/// machine's, not a key's or a card's). A count that starts over on a record carrying the flag (settings.json
|
||||
/// lost, the ladder kept) raises no first card again; a 0.3.20 record (schema 0, no flag) whose first block already
|
||||
/// came (count 1 or more, or first_block_at set) takes the flag at `start_run`, since 0.3.20 showed it every run.
|
||||
#[serde(default)]
|
||||
pub first_block_shown: bool,
|
||||
/// a window has shown the current `milestone` (set with it by `card_seen`); a seen card is dropped at the next
|
||||
/// `start_run`, an unseen one waits for its window
|
||||
#[serde(default)]
|
||||
pub milestone_seen: bool,
|
||||
/// the record's shape: 0 = written by 0.3.20 (no first-block flag), 1 = first-block-21
|
||||
#[serde(default)]
|
||||
pub schema: u32,
|
||||
}
|
||||
|
||||
/// What the dashboard reads (api/state.ladder): the record above summarised at `now`.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct LadderState {
|
||||
pub first_block_at: f64,
|
||||
pub first_block_card: String,
|
||||
pub first_block_hash: String,
|
||||
pub first_block_daa: u64,
|
||||
/// distinct UTC days with at least one block in the last 30 days
|
||||
pub days_mined_30: u64,
|
||||
pub blocks_30d: u64,
|
||||
pub blocks_today: u64,
|
||||
pub days: Vec<DayBlocks>,
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
pub blocks: Vec<BlockFound>,
|
||||
pub votes_signed: u64,
|
||||
pub last_vote_index: u64,
|
||||
pub last_vote_at: f64,
|
||||
pub last_signed_locked: u64,
|
||||
pub signed_locked_count: u64,
|
||||
/// seconds of unbroken signing (0 = none)
|
||||
pub streak_s: f64,
|
||||
pub shards: Vec<ShardPaid>,
|
||||
pub milestone: Option<Milestone>,
|
||||
pub first_synced_at: f64,
|
||||
pub first_mining_at: f64,
|
||||
/// first-block-21: the first-block card has been raised once on this machine (see `Ladder::first_block_shown`)
|
||||
pub first_block_shown: bool,
|
||||
}
|
||||
|
||||
pub fn day_of(unix: f64) -> u64 {
|
||||
(unix.max(0.0) / DAY_S).floor() as u64
|
||||
}
|
||||
|
||||
impl Ladder {
|
||||
pub fn load(path: &std::path::Path) -> Ladder {
|
||||
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
pub fn save(&self, path: &std::path::Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
let mut me = self.clone();
|
||||
me.schema = 1;
|
||||
if let Ok(t) = serde_json::to_string_pretty(&me) {
|
||||
let _ = std::fs::write(path, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's run begins over this record (first-block-21, 7 October 2026). `accepted_total` is the lifetime
|
||||
/// count settings.json holds. A block card a window has shown (`milestone_seen`) is dropped here, so a restart
|
||||
/// or an update never shows "your first block" (or block 100) a second time; a card no window has shown yet
|
||||
/// waits (found overnight, or across an auto-update restart). A 0.3.20 record (schema 0) carries no flag and
|
||||
/// showed its card on every run, so one whose first block already came (the count at 1 or more, or
|
||||
/// first_block_at set) takes the flag now and its card is dropped. Returns true when the record changed and
|
||||
/// wants saving.
|
||||
pub fn start_run(&mut self, accepted_total: u64) -> bool {
|
||||
let mut dirty = false;
|
||||
if self.schema == 0 {
|
||||
if accepted_total >= 1 || self.first_block_at > 0.0 {
|
||||
self.first_block_shown = true;
|
||||
self.milestone = None;
|
||||
}
|
||||
self.schema = 1;
|
||||
dirty = true;
|
||||
}
|
||||
if self.milestone.is_some() && self.milestone_seen {
|
||||
self.milestone = None;
|
||||
self.milestone_seen = false;
|
||||
dirty = true;
|
||||
}
|
||||
dirty
|
||||
}
|
||||
|
||||
/// A window showed (or the user dismissed) the card for the milestone at `count` and `at` (first-block-21): the
|
||||
/// card is spent, and a first-block card sets the lifetime flag. Returns true when the record changed.
|
||||
pub fn card_seen(&mut self, count: u64, at: f64) -> bool {
|
||||
let m = match self.milestone.as_ref() {
|
||||
Some(m) if m.count == count && (m.at - at).abs() < 1.0 => m,
|
||||
_ => return false,
|
||||
};
|
||||
let mut dirty = false;
|
||||
if !self.milestone_seen {
|
||||
self.milestone_seen = true;
|
||||
dirty = true;
|
||||
}
|
||||
if m.kind == "first" && !self.first_block_shown {
|
||||
self.first_block_shown = true;
|
||||
dirty = true;
|
||||
}
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The node validated a block (its own log line): remembered by nonce until the miner's line claims it.
|
||||
pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) {
|
||||
let nonce = norm_nonce(nonce);
|
||||
if nonce.is_empty() || hash.is_empty() {
|
||||
return;
|
||||
}
|
||||
// a block of ours whose miner line came first: fill the hash in now
|
||||
if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) {
|
||||
b.hash = hash.to_string();
|
||||
b.daa = daa;
|
||||
if self.first_block_hash.is_empty() && self.first_block_at == b.at {
|
||||
self.first_block_hash = hash.to_string();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
if let Some(m) = self.milestone.as_mut() {
|
||||
if m.hash.is_empty() && m.at == b.at {
|
||||
m.hash = hash.to_string();
|
||||
m.daa = daa;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending.push((nonce, hash.to_string(), daa));
|
||||
if self.pending.len() > KEEP_PENDING {
|
||||
let n = self.pending.len() - KEEP_PENDING;
|
||||
self.pending.drain(0..n);
|
||||
}
|
||||
}
|
||||
|
||||
/// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the
|
||||
/// milestone this block raised, if any.
|
||||
pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option<Milestone> {
|
||||
let nonce = norm_nonce(nonce);
|
||||
let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) {
|
||||
Some(i) => {
|
||||
let p = self.pending.remove(i);
|
||||
(p.1, p.2)
|
||||
}
|
||||
None => (String::new(), 0),
|
||||
};
|
||||
let day = day_of(now);
|
||||
match self.days.iter_mut().find(|d| d.day == day) {
|
||||
Some(d) => d.blocks += 1,
|
||||
None => self.days.push(DayBlocks { day, blocks: 1 }),
|
||||
}
|
||||
self.days.sort_by_key(|d| d.day);
|
||||
if self.days.len() > KEEP_DAYS as usize {
|
||||
let n = self.days.len() - KEEP_DAYS as usize;
|
||||
self.days.drain(0..n);
|
||||
}
|
||||
// the first-block card: once in the app's life, when the persisted lifetime count crosses from 0 to 1 on a
|
||||
// record with no block and no flag (first-block-21); a count that starts over raises nothing here
|
||||
let first = total == 1 && !self.first_block_shown && self.first_block_at == 0.0;
|
||||
if self.first_block_at == 0.0 {
|
||||
self.first_block_at = now;
|
||||
self.first_block_card = card.to_string();
|
||||
self.first_block_hash = hash.clone();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
let new_card = !card.is_empty() && !self.cards_first.contains_key(card);
|
||||
if new_card {
|
||||
self.cards_first.insert(card.to_string(), now);
|
||||
}
|
||||
self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total });
|
||||
self.blocks.truncate(KEEP_BLOCKS);
|
||||
let kind = if first { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card && total > 1 { "card" } else { "" };
|
||||
if kind.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now };
|
||||
self.milestone = Some(m.clone());
|
||||
self.milestone_seen = false;
|
||||
self.schema = 1;
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`.
|
||||
pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) {
|
||||
self.votes_signed += 1;
|
||||
if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) {
|
||||
self.streak_since = now;
|
||||
}
|
||||
self.last_vote_at = now;
|
||||
if index > self.last_vote_index {
|
||||
self.last_vote_index = index;
|
||||
}
|
||||
if index > 0 && !self.signed_open.contains(&index) {
|
||||
self.signed_open.push(index);
|
||||
if self.signed_open.len() > KEEP_SIGNED {
|
||||
let n = self.signed_open.len() - KEEP_SIGNED;
|
||||
self.signed_open.drain(0..n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now.
|
||||
pub fn on_lock(&mut self, index: u64) {
|
||||
let (locked, open): (Vec<u64>, Vec<u64>) = self.signed_open.iter().partition(|&&i| i <= index);
|
||||
if let Some(&top) = locked.iter().max() {
|
||||
if top > self.last_signed_locked {
|
||||
self.last_signed_locked = top;
|
||||
}
|
||||
self.signed_locked_count += locked.len() as u64;
|
||||
}
|
||||
self.signed_open = open;
|
||||
}
|
||||
|
||||
/// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new.
|
||||
pub fn mark(&mut self, what: &str, now: f64) -> bool {
|
||||
let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false };
|
||||
if *slot > 0.0 { return false; }
|
||||
*slot = now;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) {
|
||||
if self.shards.iter().any(|s| s.block == block && s.shard == shard) {
|
||||
return;
|
||||
}
|
||||
self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now });
|
||||
self.shards.truncate(KEEP_SHARDS);
|
||||
}
|
||||
|
||||
/// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`).
|
||||
pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 {
|
||||
if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s {
|
||||
return 0.0;
|
||||
}
|
||||
(now - self.streak_since).max(0.0)
|
||||
}
|
||||
|
||||
fn blocks_since(&self, from: f64) -> u64 {
|
||||
// the day ring is daily; the finer windows read the block list and fall back to the ring
|
||||
self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum()
|
||||
}
|
||||
|
||||
pub fn state(&self, now: f64, gap_s: f64) -> LadderState {
|
||||
let today = day_of(now);
|
||||
let window_from = now - 30.0 * DAY_S;
|
||||
let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64;
|
||||
LadderState {
|
||||
first_block_at: self.first_block_at,
|
||||
first_block_card: self.first_block_card.clone(),
|
||||
first_block_hash: self.first_block_hash.clone(),
|
||||
first_block_daa: self.first_block_daa,
|
||||
days_mined_30,
|
||||
blocks_30d: self.blocks_since(window_from),
|
||||
blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0),
|
||||
days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(),
|
||||
cards_first: self.cards_first.clone(),
|
||||
blocks: self.blocks.iter().take(10).cloned().collect(),
|
||||
votes_signed: self.votes_signed,
|
||||
last_vote_index: self.last_vote_index,
|
||||
last_vote_at: self.last_vote_at,
|
||||
last_signed_locked: self.last_signed_locked,
|
||||
signed_locked_count: self.signed_locked_count,
|
||||
streak_s: self.streak_s(now, gap_s),
|
||||
shards: self.shards.clone(),
|
||||
milestone: self.milestone.clone(),
|
||||
first_synced_at: self.first_synced_at,
|
||||
first_mining_at: self.first_mining_at,
|
||||
first_block_shown: self.first_block_shown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes).
|
||||
pub fn milestone_event(m: &Milestone) -> String {
|
||||
match m.kind.as_str() {
|
||||
"first" => format!("first block: found by {} (block card)", m.card_name),
|
||||
"hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name),
|
||||
"thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name),
|
||||
"ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name),
|
||||
_ => format!("first block on {} (block card)", m.card_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_commas(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); }
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn norm_nonce(n: &str) -> String {
|
||||
let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase();
|
||||
if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t }
|
||||
}
|
||||
|
||||
/// `PoW accepted <hash> by <engine> (daa N, ..., nonce 0x..)` -> (hash, daa, nonce)
|
||||
pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> {
|
||||
let rest = text.split("PoW accepted ").nth(1)?;
|
||||
let hash = rest.split_whitespace().next()?.to_string();
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<u64>().ok()?;
|
||||
let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string();
|
||||
Some((hash, daa, nonce))
|
||||
}
|
||||
|
||||
/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce
|
||||
pub fn parse_miner_accepted(text: &str) -> Option<String> {
|
||||
let rest = text.split("ACCEPTED block nonce=").nth(1)?;
|
||||
Some(rest.split_whitespace().next()?.to_string())
|
||||
}
|
||||
|
||||
/// `... VOTE index=N ...` -> N
|
||||
pub fn parse_vote_index(text: &str) -> Option<u64> {
|
||||
text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)";
|
||||
const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)";
|
||||
|
||||
#[test]
|
||||
fn the_node_and_miner_lines_parse() {
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce");
|
||||
assert_eq!(daa, 38637);
|
||||
assert_eq!(n, "0x267dd27200a91c80");
|
||||
assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80");
|
||||
assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240);
|
||||
assert!(parse_node_accepted("PoW rejected 00 by x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() {
|
||||
let mut l = Ladder::default();
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
// a relayed block from another miner sits in pending too and never matches
|
||||
l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa");
|
||||
let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert_eq!(m.hash, h);
|
||||
assert_eq!(m.daa, 38637);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.first_block_daa, 38637);
|
||||
assert_eq!(l.first_block_card, "apple::Apple M5 Max");
|
||||
assert_eq!(l.blocks.len(), 1);
|
||||
assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched");
|
||||
let s = l.state(1_791_140_920.0, 600.0);
|
||||
assert_eq!(s.days_mined_30, 1);
|
||||
assert_eq!(s.blocks_30d, 1);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1);
|
||||
assert_eq!(l.first_block_hash, "");
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
assert_eq!(l.blocks[0].hash, h);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().daa, 38637);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() {
|
||||
let mut l = Ladder::default();
|
||||
let mut kinds = vec![];
|
||||
for total in 1..=1_000u64 {
|
||||
if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); }
|
||||
}
|
||||
assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]);
|
||||
assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card");
|
||||
assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none());
|
||||
assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.blocks.len(), KEEP_BLOCKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_mined_counts_distinct_days_inside_the_window_only() {
|
||||
let mut l = Ladder::default();
|
||||
let now = 40.0 * DAY_S + 100.0;
|
||||
// blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window
|
||||
for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] {
|
||||
l.on_block(d * DAY_S + 50.0, "a", "A", "", total);
|
||||
}
|
||||
let s = l.state(now, 600.0);
|
||||
assert_eq!(s.days_mined_30, 3);
|
||||
assert_eq!(s.blocks_30d, 4);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
assert_eq!(s.days.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_vote(1000.0, 10, 600.0);
|
||||
l.on_vote(1030.0, 11, 600.0);
|
||||
l.on_vote(1060.0, 12, 600.0);
|
||||
assert_eq!(l.streak_s(1090.0, 600.0), 90.0);
|
||||
assert_eq!(l.votes_signed, 3);
|
||||
assert_eq!(l.last_vote_index, 12);
|
||||
// the lock at 11 puts 10 and 11 in locked certificates; 12 stays open
|
||||
l.on_lock(11);
|
||||
assert_eq!(l.last_signed_locked, 11);
|
||||
assert_eq!(l.signed_locked_count, 2);
|
||||
assert_eq!(l.signed_open, vec![12]);
|
||||
// a gap longer than the presence window ends the streak; the next vote starts a new one
|
||||
assert_eq!(l.streak_s(1700.0, 600.0), 0.0);
|
||||
l.on_vote(1700.0, 33, 600.0);
|
||||
assert_eq!(l.streak_s(1730.0, 600.0), 30.0);
|
||||
assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_milestone_events_read_in_the_users_words() {
|
||||
let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 };
|
||||
assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timeline_marks_are_set_once() {
|
||||
let mut l = Ladder::default();
|
||||
assert!(l.mark("synced", 10.0));
|
||||
assert!(!l.mark("synced", 20.0));
|
||||
assert!(l.mark("mining", 30.0));
|
||||
assert!(!l.mark("other", 40.0));
|
||||
let s = l.state(50.0, 600.0);
|
||||
assert_eq!(s.first_synced_at, 10.0);
|
||||
assert_eq!(s.first_mining_at, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_shards_are_kept_once_each_newest_first() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000);
|
||||
assert_eq!(l.shards.len(), 2);
|
||||
assert_eq!(l.shards[0].block, 1_284_120);
|
||||
assert_eq!(l.shards[1].wei, "1150000000000000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_survives_a_save_and_load() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "A", "0x1", 1);
|
||||
l.on_vote(130.0, 5, 600.0);
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id()));
|
||||
let path = dir.join("ladder.json");
|
||||
l.save(&path);
|
||||
let back = Ladder::load(&path);
|
||||
assert_eq!(back.first_block_at, 100.0);
|
||||
assert_eq!(back.votes_signed, 1);
|
||||
assert_eq!(back.blocks, l.blocks);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0);
|
||||
}
|
||||
|
||||
// ---- first-block-21 (the project lead, 7 October 2026): the first-block card SEEN once in the app's life ----
|
||||
|
||||
/// The record as 0.3.20 wrote it after the first block: the milestone persisted, no flag field, no schema.
|
||||
const RECORD_0320: &str = r#"{"first_block_at":1791140918.5,"first_block_card":"nvidia:0:RTX 4070","first_block_hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","first_block_daa":38637,"days":[{"day":20730,"blocks":1}],"cards_first":{"nvidia:0:RTX 4070":1791140918.5},"blocks":[{"hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"nonce":"267dd27200a91c80","at":1791140918.5,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","count":1}],"votes_signed":2,"last_vote_index":8496,"last_vote_at":1791140950.0,"signed_open":[8496],"last_signed_locked":0,"signed_locked_count":0,"streak_since":1791140930.0,"shards":[],"milestone":{"kind":"first","count":1,"card":"nvidia:0:RTX 4070","card_name":"NVIDIA GeForce RTX 4070","hash":"28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce","daa":38637,"at":1791140918.5},"first_synced_at":1791140000.0,"first_mining_at":1791140100.0,"pending":[]}"#;
|
||||
|
||||
fn round_trip(l: &Ladder) -> Ladder {
|
||||
// one directory per call: the tests run in parallel and a shared path let one test's remove wipe another's file
|
||||
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ladder-first-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::SeqCst)));
|
||||
let path = dir.join("ladder.json");
|
||||
l.save(&path);
|
||||
let back = Ladder::load(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
back
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_failed_a_second_run_with_the_lifetime_count_at_1_showed_the_first_card_again() {
|
||||
// the known-failed case: before this change a second run loaded the persisted milestone and the dashboard
|
||||
// showed "your first block" again (every restart, every update, until the dismiss in that window's storage)
|
||||
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert_eq!(l.milestone.as_ref().map(|m| m.kind.as_str()), Some("first"), "the fixture carries the persisted card");
|
||||
assert!(!l.first_block_shown, "the 0.3.20 record has no flag");
|
||||
assert_eq!(l.schema, 0);
|
||||
assert!(l.start_run(1), "the record changes and wants saving");
|
||||
let s = l.state(1791150000.0, 600.0);
|
||||
assert!(s.milestone.is_none(), "a card 0.3.20 showed on every run is never shown again");
|
||||
assert!(s.first_block_shown);
|
||||
assert_eq!(s.first_block_at, 1791140918.5, "the ladder's first-block facts stay");
|
||||
assert_eq!(l.schema, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_ever_block_raises_the_card_and_a_window_showing_it_sets_the_flag() {
|
||||
let mut l = Ladder::default();
|
||||
l.start_run(0);
|
||||
let m = l.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert!(!l.first_block_shown, "raised, not yet seen");
|
||||
assert!(!l.card_seen(7, 100.0), "another card's report changes nothing");
|
||||
assert!(l.card_seen(1, 100.0));
|
||||
assert!(l.first_block_shown);
|
||||
assert!(!l.card_seen(1, 100.0), "a second report (the dismiss) changes nothing");
|
||||
let s = l.state(101.0, 600.0);
|
||||
assert!(s.first_block_shown);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the card stays on screen through this run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_block_found_with_no_window_greets_the_miner_at_the_first_open_and_never_again() {
|
||||
// main's case: the block overnight (no window), an engine restart (an auto-update), the first open shows the
|
||||
// card once; a second open (the next run) does not
|
||||
let mut night = Ladder::default();
|
||||
night.start_run(0);
|
||||
night.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
let mut morning = round_trip(&night);
|
||||
assert!(!morning.start_run(1), "an unseen card waits: nothing changes at start");
|
||||
let s = morning.state(30_000.0, 600.0);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().kind, "first", "the first open shows the card");
|
||||
assert!(!s.first_block_shown);
|
||||
assert!(morning.card_seen(1, 100.0));
|
||||
assert!(morning.first_block_shown);
|
||||
let mut later = round_trip(&morning);
|
||||
assert!(later.start_run(1), "the seen card is dropped");
|
||||
assert!(later.state(60_000.0, 600.0).milestone.is_none(), "a second open shows nothing");
|
||||
assert!(later.first_block_shown);
|
||||
assert!(later.on_block(60_100.0, "a", "RTX 4070", "0x2", 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restart_with_the_count_at_1_shows_nothing_and_block_2_raises_no_card() {
|
||||
let mut run1 = Ladder::default();
|
||||
run1.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
assert!(run1.card_seen(1, 100.0), "the window was open");
|
||||
let mut run2 = round_trip(&run1);
|
||||
assert!(run2.first_block_shown, "the flag survives the save");
|
||||
assert_eq!(run2.schema, 1);
|
||||
assert!(run2.start_run(1), "the seen card is dropped");
|
||||
assert!(run2.state(200.0, 600.0).milestone.is_none());
|
||||
assert!(run2.on_block(300.0, "a", "RTX 4070", "0x2", 2).is_none(), "block 2 is an ordinary block");
|
||||
assert_eq!(run2.blocks[0].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_with_the_count_at_1_takes_the_flag_from_the_older_record_shape() {
|
||||
// the new version opens a 0.3.20 record: the flag is set from the count, the card dropped, and the flag
|
||||
// survives the next save in the new shape
|
||||
let mut l: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert!(l.start_run(1));
|
||||
assert!(l.first_block_shown);
|
||||
assert!(l.milestone.is_none());
|
||||
let mut back = round_trip(&l);
|
||||
assert!(back.first_block_shown);
|
||||
assert_eq!(back.schema, 1);
|
||||
assert!(!back.start_run(1), "a new-shape record with nothing pending changes nothing");
|
||||
assert!(back.on_block(1791150000.0, "nvidia:0:RTX 4070", "NVIDIA GeForce RTX 4070", "0x3", 2).is_none());
|
||||
// a 0.3.20 record whose count is still 0 but whose first_block_at is set (settings.json rewritten) is shown too
|
||||
let mut l2: Ladder = serde_json::from_str(RECORD_0320).unwrap();
|
||||
assert!(l2.start_run(0));
|
||||
assert!(l2.first_block_shown);
|
||||
assert!(l2.milestone.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_data_directory_with_the_count_at_0_and_the_flag_unset_shows_it_on_the_first_block() {
|
||||
// a kept directory from a machine that synced and voted but never found a block
|
||||
let mut l = Ladder::default();
|
||||
l.mark("synced", 10.0);
|
||||
l.mark("mining", 20.0);
|
||||
l.on_vote(30.0, 5, 600.0);
|
||||
let mut kept = round_trip(&l);
|
||||
assert!(!kept.start_run(0), "nothing to reconcile: no block yet");
|
||||
assert!(!kept.first_block_shown);
|
||||
let m = kept.on_block(100.0, "a", "RTX 4070", "0x1", 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert!(kept.card_seen(1, 100.0));
|
||||
assert!(kept.first_block_shown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_that_starts_over_on_a_record_already_shown_raises_no_first_card() {
|
||||
// settings.json lost (the count back at 0), the ladder kept with the flag: the next block is total 1 again
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
l.card_seen(1, 100.0);
|
||||
assert!(l.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none(), "no second first-block card");
|
||||
assert!(l.on_block(300.0, "b", "RTX 5090", "0x3", 1).is_none(), "nor a new-card card at a count of 1");
|
||||
assert!(l.first_block_shown);
|
||||
assert_eq!(l.state(301.0, 600.0).blocks.len(), 3, "the blocks are still recorded");
|
||||
// the same with the card still unseen: the waiting card stays the one card, no second is raised
|
||||
let mut u = Ladder::default();
|
||||
u.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
assert!(u.on_block(200.0, "a", "RTX 4070", "0x2", 1).is_none());
|
||||
assert_eq!(u.milestone.as_ref().unwrap().at, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_2_is_an_ordinary_block_with_no_card() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "RTX 4070", "0x1", 1);
|
||||
l.card_seen(1, 100.0);
|
||||
assert!(l.on_block(130.0, "a", "RTX 4070", "0x2", 2).is_none());
|
||||
let s = l.state(131.0, 600.0);
|
||||
assert_eq!(s.blocks[0].count, 2);
|
||||
assert_eq!(s.blocks_today, 2);
|
||||
assert_eq!(s.milestone.as_ref().unwrap().count, 1, "the first card of this run stays until the run ends");
|
||||
}
|
||||
}
|
||||
459
app/igneum-app/src/live.rs
Normal file
459
app/igneum-app/src/live.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
|
||||
//! numbers. Two sources, one contract:
|
||||
//!
|
||||
//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
|
||||
//! window, passed through untouched (`shape` adds `state.you_blocks` and `state.source` = "observer"): the scene's full
|
||||
//! feed (lanes, parents, colours, the selected chain, checkpoints, proof state). This machine's blocks are NOT rewritten:
|
||||
//! the UI marks them through the scene's `mine` option (scene/feed-contract.md: `miner` is always the 8-hex key id).
|
||||
//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
|
||||
//! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
|
||||
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
|
||||
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
|
||||
//! is unreachable the node's rows stand in for the scene in the SAME JSON shape as the observer's reply
|
||||
//! (`node_only_reply`, every key of scene/feed-contract.json, null where the node cannot know; `state.source` = "node",
|
||||
//! `partial: true`: no parents, no proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the
|
||||
//! node source rests 10 minutes. The test `the_node_only_reply_has_the_contract_shape` reads the contract file itself.
|
||||
//!
|
||||
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
|
||||
use crate::engine::Shared;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(2);
|
||||
|
||||
struct NodeCache {
|
||||
at: Option<Instant>,
|
||||
blocks: Vec<RecentBlock>,
|
||||
/// the node said "method not found" (-32601): rest until then
|
||||
retry: Option<Instant>,
|
||||
}
|
||||
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
|
||||
const NODE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// The observer's reply with this machine's blocks counted. `ids` are the 8-character vote key ids of this machine's
|
||||
/// cards; `state.you_blocks` counts the blocks whose `miner` is one of them and `state.source` names where the data came
|
||||
/// from. Every row passes through untouched (until 0.3.20 the field was rewritten to "you", which the feed contract refuses:
|
||||
/// the UI's `mine` function marks the lane from the card ids instead), so the contract holds on both surfaces.
|
||||
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
|
||||
let mut yours = 0u64;
|
||||
if let Some(blocks) = reply.get("blocks").and_then(|b| b.as_array()) {
|
||||
yours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false)).count() as u64;
|
||||
}
|
||||
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("you_blocks".into(), json!(yours));
|
||||
st.insert("source".into(), json!("observer"));
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
/// The observer URL for a window: `<live_api>?window=<s>`, the window clamped to the module's 30 to 300 s.
|
||||
pub fn url_for(live_api: &str, window_s: u32) -> String {
|
||||
format!("{live_api}?window={}", window_s.clamp(30, 300))
|
||||
}
|
||||
|
||||
/// One block from `igneum_getRecentBlocks`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecentBlock {
|
||||
pub hash: String,
|
||||
pub blue_score: u64,
|
||||
pub daa_score: u64,
|
||||
pub timestamp_ms: u64,
|
||||
pub vote_key_hash: String,
|
||||
pub is_chain_block: bool,
|
||||
pub color: String,
|
||||
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
|
||||
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
|
||||
pub timestamp_source: String,
|
||||
}
|
||||
|
||||
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
|
||||
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|b| RecentBlock {
|
||||
hash: s(b, "hash"),
|
||||
blue_score: n(b, "blue_score"),
|
||||
daa_score: n(b, "daa_score"),
|
||||
timestamp_ms: n(b, "timestamp_ms"),
|
||||
vote_key_hash: s(b, "vote_key_hash"),
|
||||
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||
color: s(b, "color"),
|
||||
timestamp_source: s(b, "timestamp_source"),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
|
||||
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
|
||||
pub fn lane(vote_key_hash: &str) -> String {
|
||||
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
|
||||
fn short(h: &str) -> String {
|
||||
h.trim_start_matches("0x").chars().take(16).collect()
|
||||
}
|
||||
|
||||
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
|
||||
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
|
||||
/// contract's row shape as far as the node knows it: no parents, no number, no proof state) and `miners` arrays
|
||||
/// (the contract's `{id, blocks, share, last_seen, engine}`). `now_ms` is the reference time.
|
||||
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
|
||||
let ten = now_ms.saturating_sub(600_000);
|
||||
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
|
||||
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
|
||||
for b in &recent {
|
||||
if !b.vote_key_hash.is_empty() {
|
||||
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut per_min = vec![0u64; 10];
|
||||
for b in &recent {
|
||||
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
|
||||
per_min[idx] += 1;
|
||||
}
|
||||
let win = now_ms.saturating_sub(window_s * 1000);
|
||||
let rows: Vec<Value> = blocks
|
||||
.iter()
|
||||
.filter(|b| b.timestamp_ms >= win)
|
||||
.map(|b| {
|
||||
let id = lane(&b.vote_key_hash);
|
||||
json!({
|
||||
"hash": short(&b.hash), "number": Value::Null, "blue_score": b.blue_score, "daa": b.daa_score,
|
||||
"ts": b.timestamp_ms, "rx": b.timestamp_ms, "parents": [], "chain": b.is_chain_block,
|
||||
"miner": if id.is_empty() { Value::Null } else { json!(id) },
|
||||
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
|
||||
"locked": false, "final": false, "shards": [], "proven": false
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"miners_10m": miners.len(),
|
||||
"blocks_10m": recent.len(),
|
||||
"blocks_per_minute": per_min,
|
||||
"blocks": rows,
|
||||
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "blocks": n, "share": if recent.is_empty() { 0.0 } else { (*n as f64 * 1000.0 / recent.len() as f64).round() / 10.0 }, "last_seen": Value::Null, "engine": Value::Null })).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The node-only reply, in the contract's shape: every key of scene/feed-contract.json present, null where the node cannot
|
||||
/// know (the observer's lag, the mempool, the proving layer), `partial: true` and `state.source` = "node" as the documented
|
||||
/// extensions, `state.node` carrying the node's own numbers as on an observer reply. `now_iso` is the clock as ISO 8601.
|
||||
pub fn node_only_reply(blocks: &[RecentBlock], now_ms: u64, now_iso: &str, window_s: u64, node: Value, ids: &HashSet<String>) -> Value {
|
||||
let mut v = shape_from_blocks(blocks, now_ms, window_s);
|
||||
let o = v.as_object_mut().unwrap();
|
||||
let rows = o.remove("blocks").unwrap_or(Value::Null);
|
||||
let miners = o.remove("miners").unwrap_or(Value::Null);
|
||||
let state = json!({
|
||||
"stale": false, "age_s": 0, "network": Value::Null, "node_version": Value::Null, "height": Value::Null,
|
||||
"block_count": node.get("block_count").cloned().unwrap_or(Value::Null), "header_count": node.get("header_count").cloned().unwrap_or(Value::Null),
|
||||
"blue_score": node.get("blue_score").cloned().unwrap_or(Value::Null), "difficulty": node.get("difficulty").cloned().unwrap_or(Value::Null),
|
||||
"hashes_per_second_estimate": Value::Null, "peers": node.get("peers").cloned().unwrap_or(Value::Null), "mempool": Value::Null,
|
||||
"blocks_60s": Value::Null, "blocks_per_second_60s": Value::Null, "blocks_per_minute": o.get("blocks_per_minute").cloned().unwrap_or(json!([])),
|
||||
"miners_10m": o.get("miners_10m").cloned().unwrap_or(json!(0)), "observer_started_at": Value::Null, "observer_lag_s": Value::Null,
|
||||
"queue_depth": Value::Null, "updated_at": now_iso, "source": "node", "node": node
|
||||
});
|
||||
let finality = json!({ "supported": false, "active": false, "next_index": Value::Null, "latest_locked_index": Value::Null, "latest_locked_hash": Value::Null,
|
||||
"latest_locked_blue_score": Value::Null, "params": Value::Null, "weights": Value::Null, "checkpoints": [] });
|
||||
let out = json!({ "ok": true, "partial": true, "now": now_iso, "state": state, "blocks": rows, "miners": miners, "events": [], "finality": finality,
|
||||
"proving": { "supported": false, "reason": "the local node's rows: no proving layer in this view" } });
|
||||
let mut out = shape(out, ids);
|
||||
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("source".into(), json!("node")); // shape() names the observer; these rows are the node's
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The clock as the contract's `now`: ISO 8601 with milliseconds, UTC.
|
||||
fn iso_now(unix_s: f64) -> String {
|
||||
let ms = (unix_s * 1000.0) as i64;
|
||||
let (secs, millis) = (ms.div_euclid(1000), ms.rem_euclid(1000));
|
||||
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm), no chrono dependency
|
||||
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
|
||||
}
|
||||
|
||||
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
|
||||
/// minutes) or answered nothing.
|
||||
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
|
||||
{
|
||||
let c = NODE.lock().unwrap();
|
||||
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
|
||||
return c.blocks.clone();
|
||||
}
|
||||
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
|
||||
if !node_up {
|
||||
return Vec::new();
|
||||
}
|
||||
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
|
||||
Ok(v) if v.is_array() => {
|
||||
let blocks = parse_recent(&v);
|
||||
let mut c = NODE.lock().unwrap();
|
||||
c.at = Some(Instant::now());
|
||||
c.blocks = blocks.clone();
|
||||
blocks
|
||||
}
|
||||
Ok(_) => Vec::new(),
|
||||
Err(e) => {
|
||||
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
|
||||
if e.contains("not found") || e.contains("-32601") {
|
||||
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
|
||||
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
|
||||
let mut v = shape_from_blocks(blocks, now_ms, 90);
|
||||
let st = shared.state.lock().unwrap();
|
||||
let o = v.as_object_mut().unwrap();
|
||||
o.remove("blocks");
|
||||
o.remove("miners");
|
||||
o.insert("block_count".into(), json!(st.node.blocks));
|
||||
o.insert("header_count".into(), json!(st.node.headers));
|
||||
o.insert("daa".into(), json!(st.node.daa));
|
||||
o.insert("blue_score".into(), json!(st.node.blue));
|
||||
o.insert("difficulty".into(), json!(st.node.difficulty));
|
||||
o.insert("peers".into(), json!(st.node.peers));
|
||||
o.insert("synced".into(), json!(st.node.synced));
|
||||
o.insert("hashes_per_second_estimate".into(), Value::Null);
|
||||
o.insert("source".into(), json!("node"));
|
||||
v
|
||||
}
|
||||
|
||||
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
|
||||
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
|
||||
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
|
||||
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
|
||||
let window_s = window_s.clamp(30, 300);
|
||||
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
|
||||
let blocks = node_blocks(shared);
|
||||
let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
|
||||
let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
|
||||
let mut reply = match cached {
|
||||
Some(v) => v,
|
||||
None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
|
||||
None => {
|
||||
let url = url_for(live_api, window_s);
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
|
||||
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
|
||||
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
|
||||
_ => json!({ "ok": false, "error": "the observer did not answer" }),
|
||||
};
|
||||
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
|
||||
}
|
||||
reply
|
||||
}
|
||||
};
|
||||
let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
|
||||
match (observer_ok, node) {
|
||||
(true, Some(n)) => {
|
||||
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||
st.insert("node".into(), n);
|
||||
}
|
||||
reply
|
||||
}
|
||||
(true, None) => reply,
|
||||
(false, Some(n)) => {
|
||||
// the node's rows stand in, in the contract's shape: blocks without parents or proof state, and the reply says so
|
||||
node_only_reply(&blocks, now_ms, &iso_now(crate::platform::unix_now_f()), window_s as u64, n, ids)
|
||||
}
|
||||
(false, None) => reply,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on
|
||||
// the selected chain and proven, one excluded; a locked and a pending checkpoint
|
||||
fn fixture() -> Value {
|
||||
json!({
|
||||
"ok": true, "now": 1791301670,
|
||||
"state": { "stale": false, "age_s": 1, "height": 198490 },
|
||||
"blocks": [
|
||||
{ "hash": "a1", "ts": 1791301600000i64, "blue_score": 194690, "daa": 198486, "parents": [], "chain": true, "color": "blue", "miner": "8fafda27", "locked": true, "final": true, "shards": [{ "state": "paid" }], "proven": true },
|
||||
{ "hash": "a2", "ts": 1791301601000i64, "blue_score": 194691, "daa": 198487, "parents": ["a1"], "chain": true, "color": "blue", "miner": "1b2c3d4e", "locked": false, "final": false, "shards": [{ "state": "proving" }], "proven": false },
|
||||
{ "hash": "a3", "ts": 1791301601500i64, "blue_score": null, "daa": 198487, "parents": ["a1"], "chain": false, "color": "red", "miner": "8fafda27", "locked": false, "final": false, "shards": [], "proven": false },
|
||||
{ "hash": "a4", "ts": 1791301602000i64, "blue_score": 194692, "daa": 198488, "parents": ["a2", "a3"], "chain": true, "color": "pending", "miner": "deadbeef", "locked": false, "final": false, "shards": [], "proven": false }
|
||||
],
|
||||
"finality": { "checkpoints": [
|
||||
{ "index": 6490, "blue_score": 194690, "daa": 198486, "state": "locked", "fraction_total": 0.71 },
|
||||
{ "index": 6491, "blue_score": 194720, "daa": 198516, "state": "pending", "fraction_total": 0.44 }
|
||||
] }
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_machines_blocks_are_counted_and_every_row_passes_through_untouched() {
|
||||
let ids: HashSet<String> = ["8fafda27".to_string(), "9a8b7c6d".to_string()].into_iter().collect();
|
||||
let out = shape(fixture(), &ids);
|
||||
let blocks = out["blocks"].as_array().unwrap();
|
||||
assert_eq!(blocks.len(), 4);
|
||||
assert_eq!(blocks[0]["miner"], "8fafda27", "the key id stays: the contract refuses a rewritten miner");
|
||||
assert_eq!(blocks[2]["miner"], "8fafda27");
|
||||
assert_eq!(blocks[1]["miner"], "1b2c3d4e");
|
||||
assert_eq!(blocks[3]["miner"], "deadbeef");
|
||||
// the rest of the contract passes through: parents, chain, colour, proof state, checkpoints
|
||||
assert_eq!(blocks[3]["parents"], json!(["a2", "a3"]));
|
||||
assert_eq!(blocks[0]["chain"], true);
|
||||
assert_eq!(blocks[2]["color"], "red");
|
||||
assert_eq!(blocks[0]["proven"], true);
|
||||
assert_eq!(blocks[1]["shards"][0]["state"], "proving");
|
||||
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
|
||||
assert_eq!(out["state"]["you_blocks"], 2);
|
||||
assert_eq!(out["state"]["source"], "observer");
|
||||
assert_eq!(out["state"]["height"], 198490);
|
||||
assert_eq!(out["ok"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ids_means_no_lane_is_yours_and_a_bare_reply_survives() {
|
||||
let out = shape(fixture(), &HashSet::new());
|
||||
assert_eq!(out["state"]["you_blocks"], 0);
|
||||
let bare = shape(json!({ "ok": true, "blocks": [] }), &HashSet::new());
|
||||
assert_eq!(bare["blocks"].as_array().unwrap().len(), 0);
|
||||
assert!(bare.get("state").is_none(), "no state object is invented");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_is_clamped_to_the_modules_range() {
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 120), "https://igneum.network/api/live?window=120");
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 5), "https://igneum.network/api/live?window=30");
|
||||
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
|
||||
}
|
||||
|
||||
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
|
||||
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
|
||||
let now = 1_791_300_000_000u64;
|
||||
let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
|
||||
let v = shape_from_blocks(&blocks, now, 120);
|
||||
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
|
||||
assert_eq!(v["blocks_10m"], 4);
|
||||
let pm = v["blocks_per_minute"].as_array().unwrap();
|
||||
assert_eq!(pm.len(), 10);
|
||||
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
|
||||
assert_eq!(pm[8], 1, "the 95 s block");
|
||||
assert_eq!(pm[1], 1, "the 500 s block");
|
||||
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
|
||||
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
|
||||
assert_eq!(v["blocks"][0]["color"], "blue");
|
||||
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
|
||||
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
|
||||
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
|
||||
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
|
||||
assert_eq!(v["miners"][0]["blocks"], 2, "the contract's miner row: blocks, not blocks_10m");
|
||||
// this machine's rows are counted, never rewritten
|
||||
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
|
||||
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
|
||||
assert_eq!(marked["blocks"][0]["miner"], "aa");
|
||||
assert_eq!(marked["state"]["you_blocks"], 2);
|
||||
assert_eq!(lane("0x0123456789abcdef"), "01234567");
|
||||
assert_eq!(lane(""), "");
|
||||
}
|
||||
|
||||
/// scene/feed-contract.json, the same file tools/scene/feed-contract.mjs reads: the node-only reply carries exactly the
|
||||
/// contract's keys (plus the documented extensions) at every level the scene reads.
|
||||
const CONTRACT: &str = include_str!("../../../scene/feed-contract.json");
|
||||
|
||||
fn keys_of(v: &Value) -> Vec<String> {
|
||||
let mut k: Vec<String> = v.as_object().expect("an object").keys().cloned().collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
fn listed(c: &Value, name: &str) -> Vec<String> {
|
||||
let mut k: Vec<String> = c[name].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();
|
||||
k.sort();
|
||||
k
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_node_only_reply_has_the_contract_shape() {
|
||||
let c: Value = serde_json::from_str(CONTRACT).expect("scene/feed-contract.json parses");
|
||||
let now = 1_791_300_000_000u64;
|
||||
let blocks = vec![b(now - 5_000, "aa11bb22", true), b(now - 30_000, "bb22cc33", true), b(now - 95_000, "aa11bb22", false), b(now - 40_000, "", true)];
|
||||
let node = json!({ "block_count": 10, "header_count": 12, "blue_score": 9, "difficulty": 1.5, "peers": 3, "synced": true, "source": "node", "miners_10m": 2, "blocks_10m": 3, "blocks_per_minute": [0,0,0,0,0,0,0,0,1,2] });
|
||||
let ids: HashSet<String> = ["aa11bb22".to_string()].into_iter().collect();
|
||||
let out = node_only_reply(&blocks, now, "2026-10-07T13:20:00.000Z", 120, node, &ids);
|
||||
// top level: the contract's keys plus the documented extensions, nothing else
|
||||
let mut top = listed(&c, "top"); top.extend(listed(&c, "top_extensions")); top.sort();
|
||||
assert_eq!(keys_of(&out), top);
|
||||
assert_eq!(out["partial"], true);
|
||||
assert_eq!(out["now"], "2026-10-07T13:20:00.000Z", "now is the ISO string, not a float of seconds");
|
||||
// state: every contract key present, extras only from the extension list
|
||||
let state = listed(&c, "state");
|
||||
let ext = listed(&c, "state_extensions");
|
||||
for k in &state { assert!(out["state"].get(k).is_some(), "state lacks {k}"); }
|
||||
for k in keys_of(&out["state"]) { assert!(state.contains(&k) || ext.contains(&k), "state carries an unknown key {k}"); }
|
||||
assert_eq!(out["state"]["source"], "node");
|
||||
assert_eq!(out["state"]["you_blocks"], 2);
|
||||
assert_eq!(out["state"]["node"]["peers"], 3);
|
||||
// rows: exactly the contract's keys
|
||||
for row in out["blocks"].as_array().unwrap() { assert_eq!(keys_of(row), listed(&c, "block")); }
|
||||
assert_eq!(out["blocks"][0]["miner"], "aa11bb22");
|
||||
assert_eq!(out["blocks"][0]["number"], Value::Null);
|
||||
assert_eq!(out["blocks"][3]["miner"], Value::Null, "a block without a key reads null, never an empty string");
|
||||
assert_eq!(out["miners"][0]["id"], "aa11bb22");
|
||||
assert_eq!(out["miners"][0]["share"], 50.0, "two of the four blocks in ten minutes");
|
||||
for m in out["miners"].as_array().unwrap() { assert_eq!(keys_of(m), listed(&c, "miner")); }
|
||||
assert_eq!(keys_of(&out["finality"]), listed(&c, "finality"));
|
||||
assert_eq!(keys_of(&out["proving"]), listed(&c, "proving_unsupported"));
|
||||
assert_eq!(out["events"], json!([]));
|
||||
// the miner id is 8 hex or null, never a word
|
||||
let re_ok = |s: &str| s.len() == 8 && s.chars().all(|ch| ch.is_ascii_hexdigit());
|
||||
for row in out["blocks"].as_array().unwrap() { if let Some(m) = row["miner"].as_str() { assert!(re_ok(m), "miner {m}"); } }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_now_formats_the_clock_as_the_contracts_now() {
|
||||
assert_eq!(iso_now(1_791_301_670.312), "2026-10-06T15:47:50.312Z");
|
||||
assert_eq!(iso_now(0.0), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(iso_now(951_782_400.5), "2000-02-29T00:00:00.500Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
|
||||
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
|
||||
let p = parse_recent(&v);
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
|
||||
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
|
||||
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
|
||||
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
|
||||
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
|
||||
assert!(parse_recent(&json!(null)).is_empty());
|
||||
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,19 +24,33 @@ mod server;
|
|||
mod state;
|
||||
mod manifest;
|
||||
mod ota;
|
||||
mod uiota;
|
||||
mod update;
|
||||
mod execrpc;
|
||||
mod jobs;
|
||||
mod jobrun;
|
||||
mod jobbuild;
|
||||
mod prover;
|
||||
mod provedefault;
|
||||
mod provingdir;
|
||||
mod segments;
|
||||
mod verifier;
|
||||
mod wslhost;
|
||||
mod sweep;
|
||||
mod ember;
|
||||
mod heat;
|
||||
mod powertask;
|
||||
mod watchdog;
|
||||
mod live;
|
||||
mod extnode;
|
||||
mod merge;
|
||||
mod ladder;
|
||||
mod chainfacts;
|
||||
mod card;
|
||||
mod drivertable;
|
||||
mod drivers;
|
||||
mod bootcheck;
|
||||
mod boot;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
@ -44,7 +58,7 @@ use std::sync::Arc;
|
|||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let wrapper = args.iter().any(|a| a == "--wrapper");
|
||||
let wrapper = args.iter().any(|a| a == "--wrapper") && !args.iter().any(|a| a == "--boot");
|
||||
let sweep = args.iter().any(|a| a == "--sweep");
|
||||
if sweep {
|
||||
// the runtime reads it (config::Runtime::from_env); the engine starts at once and quits after the sweep
|
||||
|
|
@ -61,22 +75,45 @@ fn main() {
|
|||
let dir = powertask::helper_dir();
|
||||
std::process::exit(powertask::run_helper(&dir));
|
||||
}
|
||||
// 0.3.22: `--data-root <path>` pins the data root (the boot task spells it out: an S4U session may not load the
|
||||
// profile); `--boot` is the headless engine (no window host, no browser); `--launch` with no interactive session is
|
||||
// `--boot` (src/boot.rs launch_mode: PC 2 waited 67 minutes for a logon on 7 October 2026)
|
||||
if let Some(i) = args.iter().position(|a| a == "--data-root") {
|
||||
if let Some(p) = args.get(i + 1) {
|
||||
std::env::set_var("IGNEUM_APP_DATA", p);
|
||||
}
|
||||
}
|
||||
let boot = args.iter().any(|a| a == "--boot");
|
||||
let mut no_open = no_open || boot;
|
||||
if args.iter().any(|a| a == "--launch") {
|
||||
if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) {
|
||||
let host = dir.join("Igneum Miner.exe");
|
||||
if host.exists() {
|
||||
let mut c = std::process::Command::new(&host);
|
||||
c.current_dir(&dir);
|
||||
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
|
||||
let host = dir.as_ref().map(|d| d.join("Igneum Miner.exe"));
|
||||
let host_exists = host.as_ref().map(|h| h.exists()).unwrap_or(false);
|
||||
match boot::launch_mode(std::env::var("SESSIONNAME").ok().as_deref(), host_exists) {
|
||||
boot::LaunchMode::Host => {
|
||||
if let (Some(dir), Some(host)) = (dir.as_ref(), host.as_ref()) {
|
||||
let mut c = std::process::Command::new(host);
|
||||
c.current_dir(dir);
|
||||
crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console)
|
||||
if c.spawn().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
boot::LaunchMode::Headless => no_open = true,
|
||||
boot::LaunchMode::Browser => {}
|
||||
}
|
||||
// no window host: the engine runs on its own and the dashboard opens in the default browser
|
||||
// no window host (or no logon): the engine runs on its own; the dashboard opens in the default browser only in a session
|
||||
}
|
||||
platform::clear_quarantine();
|
||||
let runtime = config::Runtime::from_env();
|
||||
// 0.3.22: a window host's engine that finds the boot engine already running under this data root becomes the
|
||||
// bridge to it (src/boot.rs) instead of a second engine on the same ports and folder
|
||||
if wrapper && !sweep {
|
||||
if let Some(url) = boot::running_engine(&runtime.app_dir) {
|
||||
std::process::exit(boot::run_bridge(&url));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::create_dir_all(&runtime.app_dir);
|
||||
let _ = std::fs::create_dir_all(&runtime.log_dir);
|
||||
platform::lock_permissions(&runtime.app_dir, true);
|
||||
|
|
@ -101,6 +138,14 @@ fn main() {
|
|||
}
|
||||
}
|
||||
let packaged = config::Packaged::load(&candidates).with_env_overrides();
|
||||
// Devnet 3 (7 October 2026): the package names the network its node joins; the runtime read above knew only the
|
||||
// environment, so it is read again with the package's suffix and peers (the environment still wins inside)
|
||||
let runtime = if packaged.node_devnet_suffix.is_some() || packaged.node_peers.is_some() {
|
||||
config::Runtime::from_env_with(packaged.node_devnet_suffix, packaged.node_peers.as_deref())
|
||||
} else {
|
||||
runtime
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&runtime.app_dir);
|
||||
let settings = config::Settings::load(&runtime.app_dir.join("settings.json"));
|
||||
// a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement)
|
||||
let settings = if sweep { settings.for_measurement() } else { settings };
|
||||
|
|
@ -155,7 +200,8 @@ fn main() {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
if wrapper {
|
||||
// 0.3.22: only an engine a host attached quits with the host (src/boot.rs stdin_close_quits)
|
||||
if boot::stdin_close_quits(wrapper, boot) {
|
||||
shared.send(engine::Cmd::Quit("the window host went away (stdin closed)"));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@
|
|||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "", "override": {...} },
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } }
|
||||
//! "tuning": { "updated": "...", "cards": { "<card model>": { "variant": "u2", "race": true, "candidates": [..] } } },
|
||||
//! "ui": { "version": "0.3.19.1", "sha256": "<64 hex>", "size": 412345, "url": "https://dl.../ui/igneum-ui-0.3.19.1.tar.gz",
|
||||
//! "min_engine": "0.3.19", "signature": "<128 hex>" }
|
||||
//! }
|
||||
//! `ui` (7 October 2026, docs/plans/ui-ota.md) is the interface channel: a tar.gz of app/igneum-app/ui the engine serves
|
||||
//! in place of its embedded copy once the hash and the entry's own Ed25519 signature (over `ui_sign_bytes`, the same
|
||||
//! release key) check; the whole manifest's signature covers it too. Removing the object is the kill switch.
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
//! `tuning` (4 October 2026, docs/design/miner-tuning.md) is the fleet's per-card kernel tuning: the engine writes it
|
||||
//! to <app data>/tuning.json and every GPU worker reads it at its next prepare (IGNEUM_TUNING_FILE).
|
||||
|
|
@ -52,6 +57,9 @@ pub struct Manifest {
|
|||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
/// Horizon frontier lane (6 October 2026): the publisher's word that this version installs even while the
|
||||
/// network's finality is paused (nothing else does); false unless the signed manifest says so
|
||||
pub urgent: bool,
|
||||
pub deadline_note: String,
|
||||
/// consensus.override: the exact object the engine writes to <app data>/override.json for igneumd's
|
||||
/// --override-params-file (for example {"difficulty_v2_activation_daa": N}); signed with the rest of the manifest.
|
||||
|
|
@ -59,6 +67,37 @@ pub struct Manifest {
|
|||
/// tuning: the per-card kernel tuning object (tools/tuning.mjs writes it, publish-manifest.sh --tuning carries
|
||||
/// it), written as is to <app data>/tuning.json for the GPU workers; signed with the rest of the manifest.
|
||||
pub tuning: Option<serde_json::Value>,
|
||||
/// ui: the interface channel (src/uiota.rs); None = no over-the-air interface is published
|
||||
pub ui: Option<UiEntry>,
|
||||
/// drivers: the per-vendor driver table (src/drivers.rs; branch driver-check, 7 October 2026), validated here and
|
||||
/// written as is to <app data>/drivers.json; None = no table published, the rows say nothing about drivers
|
||||
pub drivers: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// One published interface bundle (the manifest's `ui` object).
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct UiEntry {
|
||||
pub version: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub url: String,
|
||||
/// the lowest engine version that may serve this bundle; a newer bundle for an older engine is ignored
|
||||
pub min_engine: String,
|
||||
/// Ed25519 over `ui_sign_bytes(version, sha256, min_engine)` by the release key, 128 hex
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// The bytes the interface entry's own signature covers: a fixed tag, then the version, the hash and the engine
|
||||
/// floor, one per line. The url and the size are not covered: the hash is what the engine trusts after the download.
|
||||
pub fn ui_sign_bytes(version: &str, sha256: &str, min_engine: &str) -> Vec<u8> {
|
||||
format!("igneum-ui\n{version}\n{}\n{min_engine}\n", sha256.to_ascii_lowercase()).into_bytes()
|
||||
}
|
||||
|
||||
/// The entry's own signature against the release key (the whole manifest's signature is checked before this).
|
||||
pub fn verify_ui_entry(e: &UiEntry, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(&e.signature).and_then(|b| Signature::from_slice(&b).ok()).ok_or("interface signature is not 128 hex characters")?;
|
||||
key.verify(&ui_sign_bytes(&e.version, &e.sha256, &e.min_engine), &sig).map_err(|_| "interface signature does not verify".to_string())
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
|
|
@ -157,6 +196,8 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
// the manifest's TOP-LEVEL "urgent": true (the publisher sets it; igneum-ota-sign passes the document through)
|
||||
urgent: v.get("urgent").and_then(|u| u.as_bool()).unwrap_or(false),
|
||||
deadline_note: s(&consensus, "deadline_note"),
|
||||
override_params: match consensus.get("override") {
|
||||
Some(o) if o.is_object() && !o.as_object().unwrap().is_empty() => Some(o.clone()),
|
||||
|
|
@ -168,6 +209,40 @@ pub fn parse(text: &str) -> Result<Manifest, String> {
|
|||
Some(t) if !t.is_null() => return Err("tuning must be an object with a cards object".into()),
|
||||
_ => None,
|
||||
},
|
||||
drivers: match v.get("drivers") {
|
||||
Some(d) if d.is_object() => {
|
||||
crate::drivertable::Table::parse(d)?;
|
||||
Some(d.clone())
|
||||
}
|
||||
Some(d) if !d.is_null() => return Err("drivers must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
ui: match v.get("ui") {
|
||||
Some(u) if u.is_object() => {
|
||||
let e = UiEntry { version: s(u, "version"), sha256: s(u, "sha256").to_ascii_lowercase(), size: u.get("size").and_then(|x| x.as_u64()).unwrap_or(0), url: s(u, "url"), min_engine: s(u, "min_engine"), signature: s(u, "signature").to_ascii_lowercase() };
|
||||
if parse_version(&e.version).is_none() {
|
||||
return Err(format!("ui: version '{}' is not a version", e.version));
|
||||
}
|
||||
if parse_version(&e.min_engine).is_none() {
|
||||
return Err(format!("ui: min_engine '{}' is not a version", e.min_engine));
|
||||
}
|
||||
if !e.url.starts_with("https://") && !e.url.starts_with("http://127.0.0.1:") {
|
||||
return Err("ui: the url is not https".into());
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: sha256 is not 64 hex characters".into());
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err("ui: size is missing".into());
|
||||
}
|
||||
if e.signature.len() != 128 || !e.signature.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err("ui: signature is not 128 hex characters".into());
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
Some(u) if !u.is_null() => return Err("ui must be an object".into()),
|
||||
_ => None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +375,10 @@ pub struct Moment {
|
|||
pub boundary_eta_s: Option<i64>,
|
||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||
pub miner_busy: bool,
|
||||
/// A remote job is running on this engine (src/jobrun.rs). It holds even an urgent install: a job is bounded by its
|
||||
/// cap, and an install under it kills its process tree (PC 1, 6 October 2026, 17:52:54Z: 0.3.14 went in during a
|
||||
/// measurement job because install_asked from a two-hour-old update-now still counted as urgent).
|
||||
pub job_active: bool,
|
||||
/// How long the update has been ready and waiting.
|
||||
pub ready_for_s: u64,
|
||||
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
||||
|
|
@ -308,8 +387,16 @@ pub struct Moment {
|
|||
pub slot_ok: bool,
|
||||
/// How much of the network's identity count (/api/live, last 10 minutes) is gone right now, in percent.
|
||||
pub network_drop_pct: f64,
|
||||
/// Horizon frontier lane (6 October 2026): the network's finality is paused (the node is synced and no checkpoint
|
||||
/// has locked for FINALITY_PAUSE_S); a rollout never lands on a chain that cannot lock
|
||||
pub finality_paused: bool,
|
||||
/// the signed manifest's own urgent flag: the one thing that installs while finality is paused
|
||||
pub manifest_urgent: bool,
|
||||
}
|
||||
|
||||
/// No checkpoint lock for this long on a synced node = finality paused (the devnet locks every few minutes).
|
||||
pub const FINALITY_PAUSE_S: f64 = 15.0 * 60.0;
|
||||
|
||||
/// The percentage of identities the network may lose in 10 minutes before updates hold (we are the devnet).
|
||||
pub const NETWORK_DROP_HOLD_PCT: f64 = 30.0;
|
||||
|
||||
|
|
@ -320,6 +407,13 @@ pub fn slot_minute(id8: &str) -> u64 {
|
|||
|
||||
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
||||
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
||||
// the finality rule comes first: a fork-close or unsupported urgency does not pass it, only the manifest's flag
|
||||
if m.finality_paused && !m.manifest_urgent {
|
||||
return Err("waiting for finality: the network has not locked a checkpoint for 15 min; nothing installs on a chain that cannot lock".into());
|
||||
}
|
||||
if m.job_active {
|
||||
return Err("a remote job is running; installing when it closes".into());
|
||||
}
|
||||
if m.urgent {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -349,7 +443,10 @@ pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
|||
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
|
||||
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
|
||||
match activation_height {
|
||||
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
// Horizon polish Q4 (6 October 2026): an activation at or below the DAA has PASSED, nothing is pending; the
|
||||
// old rule read it as close, so every update since the 0.3.14 manifest said "0 blocks away, installing now",
|
||||
// stripped Later and skipped every safe-moment guard (PC 1's 17:52:54Z install under a job came through it)
|
||||
Some(h) if daa > 0 && h > daa => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
@ -382,6 +479,36 @@ mod tests {
|
|||
assert_eq!(ours, theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ui_entry_parses_and_every_bad_field_fails() {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let sk = SigningKey::from_bytes(&[3u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
let sha = "ab".repeat(32);
|
||||
let sig = hex_encode(&sk.sign(&ui_sign_bytes("1.0.1", &sha, "0.3.19")).to_bytes());
|
||||
let base = serde_json::json!({ "version": "0.3.19", "platforms": {}, "ui": { "version": "1.0.1", "sha256": sha, "size": 400000, "url": "https://dl.igneum.network/dl/t/ui/igneum-ui-1.0.1.tar.gz", "min_engine": "0.3.19", "signature": sig } });
|
||||
let m = parse(&base.to_string()).unwrap();
|
||||
let u = m.ui.clone().unwrap();
|
||||
assert_eq!(u.version, "1.0.1");
|
||||
assert_eq!(u.min_engine, "0.3.19");
|
||||
assert!(verify_ui_entry(&u, &pk).is_ok());
|
||||
let mut t = u.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed hash breaks the entry's signature");
|
||||
let mut t = u.clone();
|
||||
t.min_engine = "0.3.0".into();
|
||||
assert!(verify_ui_entry(&t, &pk).is_err(), "a changed engine floor breaks it too");
|
||||
assert!(verify_ui_entry(&u, &hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes())).is_err());
|
||||
assert_eq!(parse(r#"{"version":"0.3.19","platforms":{}}"#).unwrap().ui, None, "no ui object: the kill switch");
|
||||
for (k, v) in [("version", serde_json::json!("x")), ("min_engine", serde_json::json!("")), ("url", serde_json::json!("http://evil/x.tar.gz")), ("sha256", serde_json::json!("abc")), ("size", serde_json::json!(0)), ("signature", serde_json::json!("zz"))] {
|
||||
let mut b = base.clone();
|
||||
b["ui"][k] = v;
|
||||
assert!(parse(&b.to_string()).is_err(), "ui.{k} bad must fail");
|
||||
}
|
||||
assert!(parse(r#"{"version":"0.3.19","platforms":{},"ui":"x"}"#).is_err());
|
||||
assert_eq!(std::str::from_utf8(&ui_sign_bytes("1.0.1", "AB", "0.3.19")).unwrap(), "igneum-ui\n1.0.1\nab\n0.3.19\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuning_parses() {
|
||||
let m = parse(r#"{"version":"0.3.4","platforms":{},"tuning":{"updated":"2026-10-04T20:00:00Z","cards":{"NVIDIA_GeForce_RTX_5090":{"variant":"u2-ldg","race":true,"candidates":["u2-ldg","ldg","base"]}}}}"#).unwrap();
|
||||
|
|
@ -492,8 +619,15 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0, finality_paused: false, manifest_urgent: false };
|
||||
assert!(safe_to_apply(&base).is_ok());
|
||||
// the finality rule (Horizon frontier lane, 6 October 2026), the known-failed case: paused, no install; active, install;
|
||||
// paused with the manifest's urgent flag, install; paused with only a fork-close urgency, no install; patience never passes it
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ..base.clone() }).unwrap_err().starts_with("waiting for finality"));
|
||||
assert!(safe_to_apply(&Moment { finality_paused: false, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, manifest_urgent: true, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, urgent: true, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { finality_paused: true, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_err());
|
||||
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(0), ..base.clone() }).is_err());
|
||||
|
|
@ -502,6 +636,11 @@ mod tests {
|
|||
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
||||
// urgent beats every wait
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
|
||||
// PC 1, 6 October 2026, 17:52:54Z: a scheduled update arriving mid-job is deferred to the job's close, urgent or not
|
||||
assert!(safe_to_apply(&Moment { job_active: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, slot_ok: false, ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { job_active: false, urgent: true, slot_ok: false, ..base.clone() }).is_ok());
|
||||
// patience: an unsynced node for 6 h applies anyway
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
|
||||
|
|
@ -531,8 +670,11 @@ mod tests {
|
|||
assert!(!fork_is_close(Some(120_000), 0));
|
||||
assert!(!fork_is_close(Some(120_000), 118_199));
|
||||
assert!(fork_is_close(Some(120_000), 118_200));
|
||||
assert!(fork_is_close(Some(120_000), 120_000));
|
||||
assert!(fork_is_close(Some(120_000), 130_000));
|
||||
assert!(fork_is_close(Some(120_000), 119_999), "one block before the activation");
|
||||
// a passed activation is not close (Horizon polish Q4): at the height and after it, nothing is pending
|
||||
assert!(!fork_is_close(Some(120_000), 120_000));
|
||||
assert!(!fork_is_close(Some(120_000), 130_000));
|
||||
assert!(!fork_is_close(Some(33_000), 201_776), "the 0.3.14 manifest's case: difficulty v2 at 33,000 against PC 1's DAA");
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
|
|
|
|||
96
app/igneum-app/src/merge.rs
Normal file
96
app/igneum-app/src/merge.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s
|
||||
//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the
|
||||
//! N4 "synced" test (tip moving, a peer present) cannot see it.)
|
||||
//!
|
||||
//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network
|
||||
//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params,
|
||||
//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining.
|
||||
|
||||
/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes).
|
||||
pub const NOT_MERGING_S: f64 = 120.0;
|
||||
/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue
|
||||
/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth`
|
||||
/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on
|
||||
/// the 0.3.19 list, and the observer is the only network view until then.
|
||||
pub const DEFAULT_MERGE_DEPTH: u64 = 3_600;
|
||||
/// The words every surface uses for this state.
|
||||
pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network";
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MergeCheck {
|
||||
/// our node's sink blue score
|
||||
pub our_blue: u64,
|
||||
/// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision
|
||||
pub peer_sinks: Vec<u64>,
|
||||
/// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH)
|
||||
pub merge_depth: u64,
|
||||
/// seconds since one of our blocks last appeared in the network's view; None = never seen
|
||||
pub ours_seen_ago_s: Option<f64>,
|
||||
/// how long this machine has been mining with a synced node, in seconds
|
||||
pub mining_for_s: f64,
|
||||
}
|
||||
|
||||
/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has
|
||||
/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it).
|
||||
pub fn not_merging(c: &MergeCheck) -> bool {
|
||||
if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S {
|
||||
return false;
|
||||
}
|
||||
let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth };
|
||||
let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth));
|
||||
let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true);
|
||||
all_ahead && ours_absent
|
||||
}
|
||||
|
||||
/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the
|
||||
/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks.
|
||||
pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option<i64>)> {
|
||||
let blocks = reply.get("blocks")?.as_array()?;
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0);
|
||||
let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max();
|
||||
Some((sink, ours))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } }
|
||||
|
||||
/// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing
|
||||
/// merging. Today's sync test calls it synced; this one holds the miner.
|
||||
#[test]
|
||||
fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() {
|
||||
assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen");
|
||||
assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago");
|
||||
assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merging_node_is_left_alone() {
|
||||
assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it");
|
||||
assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision");
|
||||
assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge");
|
||||
assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_observer_reply_gives_the_sink_and_our_newest_block() {
|
||||
let r = json!({ "blocks": [
|
||||
{ "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" },
|
||||
{ "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" },
|
||||
{ "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" },
|
||||
{ "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] });
|
||||
assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000))));
|
||||
let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] });
|
||||
assert_eq!(view_of(&none_ours), Some((5, None)));
|
||||
assert_eq!(view_of(&json!({ "blocks": [] })), None);
|
||||
assert_eq!(view_of(&json!({ "ok": false })), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,8 +68,12 @@ pub enum Launch {
|
|||
|
||||
pub struct Ctx {
|
||||
pub node_synced: bool,
|
||||
/// the network's finality is paused (engine: synced and no lock for manifest::FINALITY_PAUSE_S)
|
||||
pub finality_paused: bool,
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
pub miner_busy: bool,
|
||||
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
|
||||
pub job_active: bool,
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +94,8 @@ pub struct Updater {
|
|||
dir: PathBuf,
|
||||
auto: bool,
|
||||
manifest: Option<Manifest>,
|
||||
/// driver-check: the table was written or removed since the engine last looked
|
||||
drivers_changed: bool,
|
||||
entry: Option<PlatformEntry>,
|
||||
file: Option<PathBuf>,
|
||||
staged: Option<PathBuf>,
|
||||
|
|
@ -129,6 +135,9 @@ pub struct Updater {
|
|||
live_next: Instant,
|
||||
live_busy: bool,
|
||||
live_api: String,
|
||||
/// ui-ota: the interface entry of the last verified manifest, handed to src/uiota.rs once per check
|
||||
/// (Some(None) = the channel was withdrawn: the kill switch)
|
||||
ui_change: Option<Option<manifest::UiEntry>>,
|
||||
}
|
||||
|
||||
impl Updater {
|
||||
|
|
@ -149,6 +158,7 @@ impl Updater {
|
|||
dir,
|
||||
auto,
|
||||
manifest: None,
|
||||
drivers_changed: false,
|
||||
entry: None,
|
||||
file: None,
|
||||
staged: None,
|
||||
|
|
@ -176,6 +186,7 @@ impl Updater {
|
|||
live_next: now,
|
||||
live_busy: false,
|
||||
live_api: env("IGNEUM_APP_LIVE_API").unwrap_or_else(|| live_api_from(&shared.packaged.live_page)),
|
||||
ui_change: None,
|
||||
};
|
||||
shared.log(&format!("update slot: minute {} of every hour (machine id {})", u.slot, shared.runtime.id8()));
|
||||
#[cfg(windows)]
|
||||
|
|
@ -188,6 +199,7 @@ impl Updater {
|
|||
// installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here)
|
||||
if !shared.runtime.sweep_only {
|
||||
firewall_first_run(shared);
|
||||
boot_task_first_run(shared);
|
||||
}
|
||||
}
|
||||
u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::<Vec<String>>(&t).ok()).unwrap_or_default();
|
||||
|
|
@ -197,6 +209,7 @@ impl Updater {
|
|||
u.min_supported = m.min_supported_version.clone();
|
||||
u.write_override(shared, &m);
|
||||
u.write_tuning(shared, &m);
|
||||
u.write_drivers(shared, &m);
|
||||
}
|
||||
}
|
||||
u.settle_previous(shared);
|
||||
|
|
@ -270,7 +283,49 @@ impl Updater {
|
|||
}
|
||||
}
|
||||
|
||||
/// <app data>/drivers.json: the manifest's per-vendor driver table (src/drivers.rs), written as is; the engine
|
||||
/// re-reads it and re-evaluates every card's offer on a change. A manifest without a table removes the file.
|
||||
fn write_drivers(&mut self, shared: &Arc<Shared>, m: &Manifest) {
|
||||
let path = self.app_dir.join("drivers.json");
|
||||
match &m.drivers {
|
||||
Some(t) => {
|
||||
let text = t.to_string();
|
||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(text.as_str()) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, &text) {
|
||||
shared.log(&format!("could not write {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
let n = t.get("vendors").and_then(|c| c.as_object()).map(|c| c.len()).unwrap_or(0);
|
||||
shared.event("info", &format!("driver table from the signed manifest: {n} vendor(s), updated {}", t.get("updated").and_then(|u| u.as_str()).unwrap_or("?")));
|
||||
self.drivers_changed = true;
|
||||
}
|
||||
None => {
|
||||
if path.is_file() && std::fs::remove_file(&path).is_ok() {
|
||||
shared.log("the manifest carries no driver table any more; drivers.json removed");
|
||||
self.drivers_changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The driver table changed (written or removed), once per change.
|
||||
pub fn take_drivers_change(&mut self) -> bool {
|
||||
std::mem::take(&mut self.drivers_changed)
|
||||
}
|
||||
|
||||
pub fn drivers_table(&self) -> Option<crate::drivers::Table> {
|
||||
let text = std::fs::read_to_string(self.app_dir.join("drivers.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
|
||||
crate::drivers::Table::parse(&v).ok()
|
||||
}
|
||||
|
||||
/// The tuning file, once per change (the engine only logs it: the workers read the file at their next prepare).
|
||||
/// ui-ota: the interface entry of the last check, once (None until a check has run)
|
||||
pub fn take_ui_change(&mut self) -> Option<Option<manifest::UiEntry>> {
|
||||
self.ui_change.take()
|
||||
}
|
||||
pub fn take_tuning_change(&mut self) -> Option<PathBuf> {
|
||||
self.tuning_changed.take()
|
||||
}
|
||||
|
|
@ -326,6 +381,12 @@ impl Updater {
|
|||
let rolled_back = v.get("rolled_back").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let deferred = v.get("deferred").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||
let ver = v.get("version").and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
// 0.3.21: the helper says how the app came back (ok, rolled-back, relaunched, installer-failed) and after how long
|
||||
let ret = v.get("return").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let ready_s = v.get("ready_s").and_then(|x| x.as_i64()).unwrap_or(-1);
|
||||
if !ret.is_empty() {
|
||||
shared.log(&format!("update-return: the helper reports '{ret}' for {ver}{}", if ready_s >= 0 { format!(", an engine answered after {ready_s} s") } else { ", no engine answered inside its window".to_string() }));
|
||||
}
|
||||
if !ok && deferred {
|
||||
// the installer never ran (nobody answered the administrator prompt): not a failure, it tries again
|
||||
shared.log(&format!("OTA: the update to {ver} was deferred before this start ({err}); it tries again in this machine's slot"));
|
||||
|
|
@ -372,6 +433,12 @@ impl Updater {
|
|||
self.pending.as_ref().map(|p| p.starts >= 3).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The version this engine replaced, on the first start after an update (MF-11: the read-back line the engine logs
|
||||
/// as its first act, "app <version> up after the update from <from>"); None on any other start.
|
||||
pub fn updated_from(&self) -> Option<String> {
|
||||
self.pending.as_ref().filter(|p| p.starts == 1 && p.to == self.current).map(|p| p.from.clone())
|
||||
}
|
||||
|
||||
// ---- state for the dashboard -------------------------------------------------------------------------------------
|
||||
|
||||
fn publish(&self, shared: &Arc<Shared>) {
|
||||
|
|
@ -539,7 +606,8 @@ impl Updater {
|
|||
}
|
||||
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
||||
let manifest_urgent = self.manifest.as_ref().map(|m| m.urgent).unwrap_or(false);
|
||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct , finality_paused: ctx.finality_paused, manifest_urgent };
|
||||
if !self.auto && !urgent && !self.install_asked {
|
||||
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
|
||||
return None;
|
||||
|
|
@ -668,6 +736,12 @@ impl Updater {
|
|||
shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name()));
|
||||
} else {
|
||||
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
|
||||
// an asked install (update-now) covers this one check: nothing newer, so the ask is spent.
|
||||
// Left true it made the NEXT manifest urgent hours later (PC 1, 6 October 2026, 17:52:54Z).
|
||||
if self.install_asked {
|
||||
self.install_asked = false;
|
||||
shared.log("update check: Install now asked and nothing newer is published; the ask is spent (the next manifest takes the usual slot)");
|
||||
}
|
||||
}
|
||||
self.entry = None;
|
||||
self.file = None;
|
||||
|
|
@ -678,6 +752,8 @@ impl Updater {
|
|||
self.min_supported = m.min_supported_version.clone();
|
||||
self.write_override(shared, &m);
|
||||
self.write_tuning(shared, &m);
|
||||
self.write_drivers(shared, &m);
|
||||
self.ui_change = Some(m.ui.clone());
|
||||
if let Some(e) = entry {
|
||||
if changed {
|
||||
self.file = None;
|
||||
|
|
@ -888,6 +964,10 @@ impl Updater {
|
|||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
|
||||
"-Mode", "apply", "-EnginePid", &std::process::id().to_string(), "-Installer", &staged.display().to_string(), "-Version", &to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(), "-Sha256", &entry.sha256,
|
||||
]);
|
||||
// 0.3.21 (MF-11): the helper owns the return. It keeps the exe set beside the app, launches the app itself after
|
||||
// the installer, polls the new engine's api/state, restores the kept set when nothing answers, and posts one line
|
||||
// to the intake either way (the key it needs is in igneum-app.json beside the exe; nothing on the command line).
|
||||
c.args(["-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string()]);
|
||||
let per_user = !under_program_files(&install_dir);
|
||||
shared.log(&format!("update: starting the installer first, the miners keep running: {} ({})", script.display(), if per_user { "per-user install, no administrator prompt" } else { "install in Program Files: Windows asks for administrator approval" }));
|
||||
if !per_user {
|
||||
|
|
@ -939,6 +1019,7 @@ impl Updater {
|
|||
let mut c = Command::new(crate::platform::tool("powershell"));
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-File"]).arg(&script).args([
|
||||
"-Sha256", &sha, "-Mode", "rollback", "-EnginePid", &std::process::id().to_string(), "-Installer", &p.previous_installer, "-Version", &p.to, "-Result", &result.display().to_string(), "-InstallDir", &install_dir.display().to_string(),
|
||||
"-Previous", &previous_dir_for(&install_dir).display().to_string(), "-Machine", &shared.runtime.id8(), "-Intake", &shared.packaged.log_intake_url, "-AppDir", &self.app_dir.display().to_string(),
|
||||
]);
|
||||
spawn_detached(&mut c)?;
|
||||
Ok(())
|
||||
|
|
@ -954,7 +1035,7 @@ impl Updater {
|
|||
// ---- the threads ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
|
||||
fn live_api_from(live_page: &str) -> String {
|
||||
pub fn live_api_from(live_page: &str) -> String {
|
||||
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
|
||||
let host = rest.split('/').next().unwrap_or("");
|
||||
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
|
||||
|
|
@ -977,6 +1058,24 @@ fn under_program_files(dir: &Path) -> bool {
|
|||
/// Windows, per-user installs: the inbound firewall rule for igneumd.exe needs administrator approval once. Asked on
|
||||
/// the first run only, in a thread; declined or unanswered, the node still dials out and mines (other nodes cannot
|
||||
/// dial in), and it is never asked again. The administrator installer of 0.3.2 and earlier added the rule itself.
|
||||
/// Windows: the boot task (src/boot.rs) registered at every engine start when it is missing, in a thread, as the
|
||||
/// user's own task (no prompt). An account Windows refuses gets it in the one approved step with the Power Helper.
|
||||
#[cfg(windows)]
|
||||
fn boot_task_first_run(shared: &Arc<Shared>) {
|
||||
let Some(exe) = std::env::current_exe().ok() else { return };
|
||||
let exe = crate::boot::task_exe(&exe);
|
||||
if !exe.is_file() {
|
||||
return;
|
||||
}
|
||||
let root = crate::platform::fixed_data_root();
|
||||
let shared = shared.clone();
|
||||
std::thread::spawn(move || match crate::boot::ensure_registered(&exe, &root) {
|
||||
Ok(true) => shared.log(&format!("boot start: the task '{}' is registered: the engine starts at boot without a logon ({} --launch --data-root {})", crate::boot::TASK_NAME, exe.display(), root.display())),
|
||||
Ok(false) => {}
|
||||
Err(e) => shared.log(&format!("boot start: not registered ({e}); the app starts at logon only until Power control's one approved step registers it")),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn firewall_first_run(shared: &Arc<Shared>) {
|
||||
let flag = shared.runtime.app_dir.join("firewall-rule.json");
|
||||
|
|
@ -1026,6 +1125,202 @@ fn installer_name_for(version: &str) -> String {
|
|||
format!("Igneum-Miner-Setup-{version}.exe")
|
||||
}
|
||||
|
||||
/// Windows: the folder the helper keeps the running exe set in before the installer runs, beside the app
|
||||
/// (`<install dir>.previous`, so `...\Programs\Igneum Miner.previous`). A rollback copies it back over the install folder.
|
||||
#[allow(dead_code)]
|
||||
fn previous_dir_for(install_dir: &Path) -> PathBuf {
|
||||
let name = install_dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Igneum Miner".into());
|
||||
install_dir.with_file_name(format!("{name}.previous"))
|
||||
}
|
||||
|
||||
// ---- the return after a Windows install (MF-11, 0.3.21) ------------------------------------------------------------
|
||||
//
|
||||
// PC 2 took the 0.3.19 update-now at 10:34Z on 7 October 2026 and was silent from 10:46Z. Until 0.3.21 the Windows helper's
|
||||
// job ended when the installer exited 0: the installer's own [Run] entry relaunched the app, nobody checked that an engine
|
||||
// answered, the window host never restarted an engine that died, and a second launch deferred to a surviving host through
|
||||
// its single-instance mutex. The sequence below is what the helper does from the installer's exit on, written once here
|
||||
// so a test can drive it with injected exit codes and answers, and mirrored line for line by WIN_HELPER's PowerShell.
|
||||
|
||||
/// How long the helper waits for an engine to answer api/state after a launch, and how often it asks.
|
||||
pub const RETURN_READY_S: u64 = 120;
|
||||
pub const RETURN_POLL_S: u64 = 3;
|
||||
|
||||
/// One step of the helper after the installer exits.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReturnStep {
|
||||
/// start igneum-app.exe --launch from the install folder (a detached process; the helper outlives the old engine)
|
||||
Launch,
|
||||
/// poll app.url + api/state for RETURN_READY_S; `want` is the version that must answer ("" = any engine)
|
||||
WaitReady { want: String },
|
||||
/// quit through the API, then end what is left by name (the installer's own stop order)
|
||||
StopAll,
|
||||
/// copy the kept exe set (`<install dir>.previous`) back over the install folder
|
||||
RestorePrevious,
|
||||
/// the result file for the next engine and the one intake line
|
||||
Done { ok: bool, rolled_back: bool, fault: bool, how: &'static str },
|
||||
}
|
||||
|
||||
/// What a WaitReady step saw: the version that answered, or nobody.
|
||||
pub type Answer = Option<String>;
|
||||
|
||||
/// The helper's sequence from the installer's exit code on. `answers` is consulted once per WaitReady, in order (the test
|
||||
/// injects them; the PowerShell asks the engine). `previous_kept` says whether the exe set was copied aside before the
|
||||
/// installer ran.
|
||||
pub fn return_sequence(installer_exit: i32, version: &str, previous_kept: bool, mut answers: impl FnMut(usize) -> Answer) -> Vec<ReturnStep> {
|
||||
let mut steps = vec![ReturnStep::Launch];
|
||||
let want = if installer_exit == 0 { version.to_string() } else { String::new() };
|
||||
steps.push(ReturnStep::WaitReady { want: want.clone() });
|
||||
match answers(0) {
|
||||
Some(v) if want.is_empty() || v == want => {
|
||||
steps.push(if installer_exit == 0 { ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" } } else { ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" } });
|
||||
return steps;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// nothing (or the wrong engine) answered inside the window: back to the kept version
|
||||
steps.push(ReturnStep::StopAll);
|
||||
if previous_kept {
|
||||
steps.push(ReturnStep::RestorePrevious);
|
||||
steps.push(ReturnStep::Launch);
|
||||
steps.push(ReturnStep::WaitReady { want: String::new() });
|
||||
let how = if answers(1).is_some() { "rolled-back" } else { "rolled-back-silent" };
|
||||
steps.push(ReturnStep::Done { ok: false, rolled_back: true, fault: true, how });
|
||||
} else {
|
||||
steps.push(ReturnStep::Launch);
|
||||
steps.push(ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" });
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// The helper before 0.3.21, for the record: the installer's exit code alone decided the result and nothing was asked
|
||||
/// of the new engine (the known-failed shape of MF-11).
|
||||
pub fn legacy_return_sequence(installer_exit: i32) -> Vec<ReturnStep> {
|
||||
if installer_exit == 0 { vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }] } else { vec![ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: false, how: "" }] }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod return_tests {
|
||||
use super::*;
|
||||
fn done(steps: &[ReturnStep]) -> &ReturnStep {
|
||||
steps.last().unwrap()
|
||||
}
|
||||
fn answers(list: &[Answer]) -> impl FnMut(usize) -> Answer + '_ {
|
||||
move |i| list.get(i).cloned().flatten()
|
||||
}
|
||||
|
||||
/// Known-failed first: the helper before 0.3.21 reported ok on the installer's exit 0 with nobody answering.
|
||||
#[test]
|
||||
fn the_legacy_helper_reports_ok_with_no_engine_up() {
|
||||
let steps = legacy_return_sequence(0);
|
||||
assert_eq!(steps, vec![ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "" }]);
|
||||
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::WaitReady { .. })), "nothing was asked of the new engine");
|
||||
assert!(!steps.iter().any(|s| matches!(s, ReturnStep::Launch)), "the launch was the installer's, not the helper's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_ok_and_the_new_engine_answers_is_ok() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.21".into())]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::Done { ok: true, rolled_back: false, fault: false, how: "ok" }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_ok_and_nobody_answers_restores_the_previous_exe_set() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
|
||||
assert_eq!(steps, vec![
|
||||
ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::RestorePrevious, ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() },
|
||||
ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back" },
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_engine_still_answering_after_exit_0_is_not_the_new_one() {
|
||||
// the installer said 0 but never replaced the running engine (files in use): the old version answers, the window
|
||||
// ends in a rollback to the kept set, which is the same version, and a FAULT line says so
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[Some("0.3.20".into()), Some("0.3.20".into())]));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_answers_twice_is_still_reported() {
|
||||
let steps = return_sequence(0, "0.3.21", true, answers(&[None, None]));
|
||||
assert_eq!(*done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, how: "rolled-back-silent" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_kept_set_the_helper_relaunches_what_is_there_and_reports() {
|
||||
let steps = return_sequence(0, "0.3.21", false, answers(&[None]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: "0.3.21".into() }, ReturnStep::StopAll, ReturnStep::Launch, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "relaunched" }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_installer_relaunches_the_old_version_and_reports_a_fault() {
|
||||
// exit 5 (cancelled) or 8 (files in use, a restart wanted): any engine answering is the old one, kept, with a FAULT line
|
||||
for code in [1, 5, 8] {
|
||||
let steps = return_sequence(code, "0.3.21", true, answers(&[Some("0.3.20".into())]));
|
||||
assert_eq!(steps, vec![ReturnStep::Launch, ReturnStep::WaitReady { want: String::new() }, ReturnStep::Done { ok: false, rolled_back: false, fault: true, how: "installer-failed" }], "exit {code}");
|
||||
}
|
||||
let steps = return_sequence(5, "0.3.21", true, answers(&[None, Some("0.3.20".into())]));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { ok: false, rolled_back: true, fault: true, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sequence_launches_before_it_waits_and_ends_in_a_done() {
|
||||
for code in [0, 1, 5, 8] {
|
||||
for kept in [true, false] {
|
||||
for a in [vec![None, None], vec![Some("0.3.21".to_string()), None], vec![None, Some("0.3.20".to_string())]] {
|
||||
let steps = return_sequence(code, "0.3.21", kept, answers(&a));
|
||||
assert_eq!(steps[0], ReturnStep::Launch);
|
||||
assert!(matches!(steps[1], ReturnStep::WaitReady { .. }));
|
||||
assert!(matches!(done(&steps), ReturnStep::Done { .. }));
|
||||
let fault = matches!(done(&steps), ReturnStep::Done { fault: true, .. });
|
||||
let ok = matches!(done(&steps), ReturnStep::Done { ok: true, .. });
|
||||
assert!(ok != fault, "a result is ok or a fault, never neither: {steps:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 0.3.22: an update applied with nobody logged on (the boot engine) returns headless: the helper's Launch runs
|
||||
/// `igneum-app.exe --launch`, which with no interactive session runs the engine itself, so the same sequence returns
|
||||
/// the app without a logon (the known-failed form first: before 0.3.22 that launch opened the window host, which has
|
||||
/// no desktop in session 0, and nothing mined until a logon)
|
||||
#[test]
|
||||
fn after_an_update_with_no_logon_the_relaunch_is_headless() {
|
||||
assert_eq!(crate::boot::legacy_launch_mode(true), crate::boot::LaunchMode::Host);
|
||||
assert_eq!(crate::boot::launch_mode(None, true), crate::boot::LaunchMode::Headless);
|
||||
let steps = return_sequence(0, "0.3.22", true, |_| Some("0.3.22".into()));
|
||||
assert_eq!(steps[0], ReturnStep::Launch, "the helper's launch is the same line; the session decides what it runs");
|
||||
assert!(matches!(steps.last().unwrap(), ReturnStep::Done { ok: true, .. }));
|
||||
}
|
||||
|
||||
/// The PowerShell mirrors the sequence: every step has its line, in order, and the installer is told the helper relaunches.
|
||||
#[test]
|
||||
fn the_windows_helper_carries_every_step() {
|
||||
let h = WIN_HELPER;
|
||||
let at = |s: &str| h.find(s).unwrap_or_else(|| panic!("WIN_HELPER lacks '{s}'"));
|
||||
assert!(at("/IGNOTA=2") > 0, "the installer must not relaunch (IGNOTA=1 is the old helpers' path)");
|
||||
assert!(h.contains(&format!("$ReadyS = {RETURN_READY_S}")) && h.contains(&format!("$PollS = {RETURN_POLL_S}")));
|
||||
let robocopy_keep = at("robocopy $InstallDir $Previous");
|
||||
let installer = at("Start-Process -FilePath $Installer"); // console: a test marker, not a spawn (the helper line above it carries the comment)
|
||||
let launch = at("function Launch()");
|
||||
let wait = at("function WaitReady(");
|
||||
let stop = at("function StopAll()");
|
||||
let restore = at("robocopy $Previous $InstallDir");
|
||||
let report = at("function Report(");
|
||||
assert!(launch < installer && wait < installer && stop < installer && report < installer, "the functions are defined before the installer runs");
|
||||
assert!(robocopy_keep < installer && restore < installer, "the keep and the restore are functions defined before the installer line");
|
||||
assert!(at("$kept = KeepPrevious") < installer, "the exe set is kept before the installer runs");
|
||||
assert!(at("Comeback $code $kept") > installer, "the return runs after the installer");
|
||||
for marker in ["'ok'", "'installer-failed'", "'rolled-back'", "'rolled-back-silent'", "'relaunched'"] {
|
||||
assert!(h.contains(marker), "the helper never writes return={marker}");
|
||||
}
|
||||
assert!(h.contains("FAULT update-return:"), "the fault line");
|
||||
assert!(h.contains("update-return: ok"), "the ok line");
|
||||
assert!(h.contains("api/state"), "readiness is the engine's own answer");
|
||||
assert!(!h.contains("-IntakeKey"), "no key travels on a command line (R4.3.8)");
|
||||
}
|
||||
}
|
||||
|
||||
fn curl(args: &[&str], limit: Duration) -> Result<(), String> {
|
||||
let mut c = Command::new(crate::platform::tool("curl"));
|
||||
c.args(args);
|
||||
|
|
@ -1261,60 +1556,180 @@ case "$MODE" in
|
|||
esac
|
||||
"#;
|
||||
|
||||
#[cfg(windows)]
|
||||
/// The Windows helper. Not cfg-gated so the return test above can read it on every platform.
|
||||
#[allow(dead_code)]
|
||||
const WIN_HELPER: &str = r#"# Igneum Miner update helper, written by the engine (src/ota.rs). Not for running by hand.
|
||||
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex>
|
||||
# powershell -File ota-apply.ps1 -Mode apply|rollback -EnginePid <pid> -Installer <setup exe> -Version <v> -Result <json> -InstallDir <folder> -Sha256 <hex> -Previous <folder> -Machine <id8> -Intake <url> -AppDir <app data>
|
||||
# The installer runs FIRST, while the engine keeps mining (4 October 2026: two unattended PCs sat stopped at an
|
||||
# administrator prompt nobody could click). A per-user installer (0.3.3 and later, PrivilegesRequired=lowest) needs no
|
||||
# prompt; an older administrator installer raises one through ShellExecute. Only when the installer actually runs does
|
||||
# its PrepareToInstall step stop the engine (api/quit: miners first, then the node), replace the files and relaunch
|
||||
# the app (/IGNOTA=1). A declined, timed-out or unanswered prompt leaves the engine running: the result says
|
||||
# deferred:true and the engine shows "waits for the next time someone is at this PC". The old app is relaunched only
|
||||
# when the engine is gone and the install did not happen.
|
||||
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '')
|
||||
# its PrepareToInstall step stop the engine (api/quit: miners first, then the node) and replace the files. A declined,
|
||||
# timed-out or unanswered prompt leaves the engine running: the result says deferred:true and the engine shows "waits for
|
||||
# the next time someone is at this PC".
|
||||
# From 0.3.21 (MF-11, 7 October 2026) this helper owns the return: it keeps the running exe set beside the app before the
|
||||
# installer runs, starts the app itself afterwards (/IGNOTA=2 tells the installer not to), waits for an engine to answer
|
||||
# api/state with the new version, restores the kept set when nothing answers inside the window, and posts one line to
|
||||
# the log intake either way (the key is read from igneum-app.json beside the exe, never from the command line). The
|
||||
# sequence is src/ota.rs return_sequence(), tested there with injected exit codes; this file mirrors it step for step.
|
||||
param([string]$Mode, [int]$EnginePid, [string]$Installer, [string]$Version, [string]$Result, [string]$InstallDir, [string]$Sha256 = '', [string]$Previous = '', [string]$Machine = '', [string]$Intake = '', [string]$AppDir = '')
|
||||
$log = Join-Path (Split-Path -Parent $Result) 'ota-apply.log'
|
||||
if (-not $AppDir) { $AppDir = Split-Path -Parent $Result }
|
||||
$ReadyS = 120
|
||||
$PollS = 3
|
||||
function Log([string]$t) { Add-Content -Path $log -Value ("{0} {1}" -f (Get-Date -Format s), $t) }
|
||||
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred) {
|
||||
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s) }
|
||||
function Done([bool]$ok, [string]$err, [bool]$rb, [bool]$deferred, [string]$ret, [int]$readyS) {
|
||||
$o = @{ ok = $ok; version = $Version; error = $err; rolled_back = $rb; deferred = $deferred; at = [int][double](Get-Date -UFormat %s); 'return' = $ret; ready_s = $readyS }
|
||||
($o | ConvertTo-Json -Compress) | Set-Content -Path $Result -Encoding ASCII
|
||||
}
|
||||
function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction SilentlyContinue) }
|
||||
function Relaunch() {
|
||||
if (EngineAlive) { return }
|
||||
$exe = Join-Path $InstallDir 'igneum-app.exe'
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
|
||||
if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null }
|
||||
function AppUrl() {
|
||||
$f = Join-Path $AppDir 'app.url'
|
||||
if (Test-Path $f) { return (Get-Content $f -Raw).Trim() }
|
||||
return ''
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining until the installer runs)"
|
||||
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false; exit 1 }
|
||||
function AppVersion() {
|
||||
# the engine's own answer: GET <app.url>api/state and its version field (the URL file is rewritten by every start)
|
||||
$u = AppUrl
|
||||
if (-not $u) { return '' }
|
||||
try { $r = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$r.version } catch { return '' }
|
||||
}
|
||||
function WaitReady([string]$want, [int]$limitS) {
|
||||
# the seconds until an engine answered with the wanted version (any version when $want is empty); -1 when none did
|
||||
$t0 = Get-Date
|
||||
while (((Get-Date) - $t0).TotalSeconds -lt $limitS) {
|
||||
$v = AppVersion
|
||||
if ($v -and (($want -eq '') -or ($v -eq $want))) { return [int]((Get-Date) - $t0).TotalSeconds }
|
||||
Start-Sleep -Seconds $PollS
|
||||
}
|
||||
return -1
|
||||
}
|
||||
function Launch() {
|
||||
$exe = Join-Path $InstallDir 'igneum-app.exe'
|
||||
if (-not (Test-Path $exe)) { Log ("nothing to start: " + $exe + " is missing"); return $false }
|
||||
Log ("starting " + $exe + " --launch")
|
||||
# console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens
|
||||
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null
|
||||
return $true
|
||||
}
|
||||
function StopAll() {
|
||||
# the quit through the API first (miners, then the node), then whatever is left by name: the installer's own order
|
||||
$u = AppUrl
|
||||
if ($u) { try { Invoke-WebRequest -Uri ($u + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null } catch { } }
|
||||
$deadline = (Get-Date).AddSeconds(40)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
foreach ($n in @('Igneum Miner', 'igneum-app', 'igneum-miner', 'igneum-worker-cuda', 'igneum-worker-opencl', 'igneumd')) {
|
||||
Get-Process -Name $n -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
function Report([string]$line) {
|
||||
# one line to the log intake, label fault-win-<id8>, as site/api/log.mjs expects; the key is the one the installed
|
||||
# app carries (igneum-app.json beside the exe, or the kept copy); nothing to post with is logged, never fatal
|
||||
Log $line
|
||||
if (-not $Intake -or -not $Machine) { return }
|
||||
$cfg = Join-Path $InstallDir 'igneum-app.json'
|
||||
if (-not (Test-Path $cfg) -and $Previous) { $cfg = Join-Path $Previous 'igneum-app.json' }
|
||||
if (-not (Test-Path $cfg)) { Log 'no igneum-app.json to read the intake key from; the line stays in this log'; return }
|
||||
try {
|
||||
$key = [string](Get-Content $cfg -Raw | ConvertFrom-Json).log_intake_key
|
||||
if (-not $key) { Log 'igneum-app.json carries no intake key'; return }
|
||||
$o = @{ label = ('fault-win-' + $Machine); machine = ($env:COMPUTERNAME + '-' + $Machine); run_id = ('update-return-' + $Version); lines = ("IGNEUM-APP version=" + $Version + " machine=" + $Machine + " platform=windows node=?`n" + $line) }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($o | ConvertTo-Json -Compress))
|
||||
Invoke-RestMethod -Method Post -Uri $Intake -Headers @{ 'x-igneum-key' = $key } -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 30 | Out-Null
|
||||
Log 'intake line posted'
|
||||
} catch { Log ('intake post failed: ' + $_.Exception.Message) }
|
||||
}
|
||||
function KeepPrevious() {
|
||||
# the running exe set beside the app, what a rollback restores; packs, build and dist are rebuilt or unneeded
|
||||
if (-not $Previous) { return $false }
|
||||
try {
|
||||
& robocopy $InstallDir $Previous /MIR /XD packs build dist /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8 -and (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { Log ("previous version kept at " + $Previous); return $true }
|
||||
Log ("robocopy could not keep the previous version (exit " + $LASTEXITCODE + ")")
|
||||
} catch { Log ('could not keep the previous version: ' + $_.Exception.Message) }
|
||||
return $false
|
||||
}
|
||||
function RestorePrevious() {
|
||||
if (-not $Previous -or -not (Test-Path (Join-Path $Previous 'igneum-app.exe'))) { return $false }
|
||||
try {
|
||||
& robocopy $Previous $InstallDir /MIR /XD packs build dist /R:2 /W:2 /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -lt 8) { Log 'previous version restored over the install folder'; return $true }
|
||||
Log ("robocopy could not restore the previous version (exit " + $LASTEXITCODE + ")")
|
||||
} catch { Log ('could not restore the previous version: ' + $_.Exception.Message) }
|
||||
return $false
|
||||
}
|
||||
function Comeback([int]$code, [bool]$kept) {
|
||||
# src/ota.rs return_sequence(): Launch, WaitReady, then Done or StopAll, RestorePrevious, Launch, WaitReady, Done
|
||||
if (-not (EngineAlive)) { Launch | Out-Null } else { Log 'the engine is still up after the installer (it did not stop it)' }
|
||||
$want = ''
|
||||
if ($code -eq 0) { $want = $Version }
|
||||
$ready = WaitReady $want $ReadyS
|
||||
if ($ready -ge 0) {
|
||||
if ($code -eq 0) {
|
||||
Done $true '' $false $false 'ok' $ready
|
||||
Report ("update-return: ok " + $Version + " up in " + $ready + " s")
|
||||
exit 0
|
||||
}
|
||||
Done $false ("the installer exited with code " + $code + " (see ota-setup.log); the previous version answers again") $false $false 'installer-failed' $ready
|
||||
Report ("FAULT update-return: the installer of " + $Version + " exited with code " + $code + "; the previous version answered again after " + $ready + " s")
|
||||
exit 1
|
||||
}
|
||||
Log ("no engine answered api/state with '" + $want + "' inside " + $ReadyS + " s; back to the previous version")
|
||||
StopAll
|
||||
if ($kept -and (RestorePrevious)) {
|
||||
Launch | Out-Null
|
||||
$r2 = WaitReady '' $ReadyS
|
||||
if ($r2 -ge 0) {
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored") $true $false 'rolled-back' $r2
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s after the install; the previous exe set was restored and answers after " + $r2 + " s")
|
||||
exit 1
|
||||
}
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; the previous version was restored but did not answer either") $true $false 'rolled-back-silent' -1
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s; the previous exe set was restored and did not answer inside " + $ReadyS + " s either; a hand start is needed on this PC")
|
||||
exit 1
|
||||
}
|
||||
Launch | Out-Null
|
||||
Done $false ("Igneum Miner " + $Version + " did not answer within " + $ReadyS + " s; no kept version to restore; what is installed was started again") $false $false 'relaunched' -1
|
||||
Report ("FAULT update-return: " + $Version + " did not answer inside " + $ReadyS + " s and no previous exe set was kept; what is installed was started again")
|
||||
exit 1
|
||||
}
|
||||
Log "$Mode : engine $EnginePid installer '$Installer' version $Version previous '$Previous' (the engine keeps mining until the installer runs)"
|
||||
if (-not (Test-Path $Installer)) { Log 'installer missing'; Done $false 'the downloaded installer is missing' $false $false '' -1; exit 1 }
|
||||
# the installer is hashed again right before it runs (R4.3.5)
|
||||
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false; exit 1 }
|
||||
if (-not $Sha256) { Log 'no sha256 given'; Done $false 'no sha256 for the installer; not run' $false $false '' -1; exit 1 }
|
||||
$have = (Get-FileHash -Path $Installer -Algorithm SHA256).Hash.ToLower()
|
||||
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false; exit 1 }
|
||||
if ($have -ne $Sha256.ToLower()) { Log "sha256 mismatch: $have"; Remove-Item -Path $Installer -Force -ErrorAction SilentlyContinue; Done $false 'the installer changed since it was verified; not run' $false $false '' -1; exit 1 }
|
||||
Log 'installer sha256 verified'
|
||||
$kept = $false
|
||||
if ($Mode -eq 'apply') { $kept = KeepPrevious }
|
||||
$setupLog = Join-Path (Split-Path -Parent $Result) 'ota-setup.log'
|
||||
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=1', ('/LOG="' + $setupLog + '"'))
|
||||
$setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', '/IGNOTA=2', ('/LOG="' + $setupLog + '"'))
|
||||
$code = -1
|
||||
try {
|
||||
# no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or
|
||||
# timed-out prompt comes back here as an exception with the engine still mining
|
||||
# console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing
|
||||
$p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru
|
||||
if ($p.ExitCode -eq 0) {
|
||||
if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true $false }
|
||||
else { Done $true '' $false $false }
|
||||
Log 'installer exit 0'
|
||||
exit 0
|
||||
}
|
||||
Log ("installer exit " + $p.ExitCode)
|
||||
Done $false ("the installer exited with code " + $p.ExitCode + " (see ota-setup.log)") $false $false
|
||||
Relaunch
|
||||
exit 1
|
||||
$code = $p.ExitCode
|
||||
} catch {
|
||||
$msg = $_.Exception.Message
|
||||
Log ("installer did not run: " + $msg)
|
||||
Log 'OTA: waiting for administrator approval; the engine keeps mining; the update waits for the next time someone is at this PC'
|
||||
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true
|
||||
Relaunch
|
||||
Done $false ("waiting for administrator approval (" + $msg + ")") $false $true '' -1
|
||||
if (-not (EngineAlive)) { Launch | Out-Null }
|
||||
exit 1
|
||||
}
|
||||
Log ("installer exit " + $code)
|
||||
if ($Mode -eq 'rollback') {
|
||||
# the kept installer of the previous version ran: the same return, reported as the rollback it is
|
||||
if (-not (EngineAlive)) { Launch | Out-Null }
|
||||
$r = WaitReady '' $ReadyS
|
||||
Done $false ("Igneum Miner " + $Version + " did not stay up twice; the previous version was reinstalled") $true $false 'rolled-back' $r
|
||||
Report ("FAULT update-return: " + $Version + " did not stay up twice; the previous version was reinstalled (installer exit " + $code + ", answered after " + $r + " s)")
|
||||
exit 1
|
||||
}
|
||||
Comeback $code $kept
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub fn tool(name: &str) -> PathBuf {
|
|||
let sys = format!("{root}\\System32");
|
||||
let p = match name {
|
||||
"powershell" => format!("{sys}\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||||
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" => format!("{sys}\\{name}.exe"),
|
||||
"cmd" | "curl" | "reg" | "icacls" | "taskkill" | "w32tm" | "net" | "tar" | "wsl" | "wevtutil" | "robocopy" => format!("{sys}\\{name}.exe"),
|
||||
"nvidia-smi" => {
|
||||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".into());
|
||||
let a = format!("{pf}\\NVIDIA Corporation\\NVSMI\\nvidia-smi.exe");
|
||||
|
|
@ -206,6 +206,17 @@ pub fn open_url(url: &str) {
|
|||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with
|
||||
/// the file selected, the folder on other systems.
|
||||
pub fn reveal_file(path: &std::path::Path) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new(tool("open")).arg("-R").arg(path).spawn();
|
||||
#[cfg(windows)]
|
||||
let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn();
|
||||
}
|
||||
|
||||
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
|
||||
/// which must be refreshed (call `keep_awake_tick` every minute).
|
||||
pub struct KeepAwake {
|
||||
|
|
@ -257,6 +268,49 @@ pub fn keep_awake_tick() {
|
|||
}
|
||||
|
||||
/// Asks a child to stop. Unix: SIGTERM (the node closes its database cleanly). Windows: TerminateProcess through
|
||||
/// Every `igneum-miner` process on this machine with its command line: (pid, command line). Windows reads
|
||||
/// Win32_Process through PowerShell; unix reads `ps`. An empty list when the tool fails (the caller kills nothing).
|
||||
pub fn miner_processes() -> Vec<(u32, String)> {
|
||||
let out = if cfg!(windows) {
|
||||
let mut c = std::process::Command::new(tool("powershell"));
|
||||
c.args(["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='igneum-miner.exe'\" | ForEach-Object { \"$($_.ProcessId)|$($_.CommandLine)\" }"]);
|
||||
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(20))
|
||||
} else {
|
||||
let mut c = std::process::Command::new("ps");
|
||||
c.args(["-eo", "pid=,args="]);
|
||||
crate::detect::run_timeout(&mut c, None, std::time::Duration::from_secs(10))
|
||||
};
|
||||
let Some(out) = out else { return vec![] };
|
||||
let mut v = Vec::new();
|
||||
for l in out.lines() {
|
||||
let l = l.trim();
|
||||
let (pid, cmd) = if cfg!(windows) {
|
||||
let Some((p, c)) = l.split_once('|') else { continue };
|
||||
(p.trim(), c.trim())
|
||||
} else {
|
||||
let Some((p, c)) = l.split_once(' ') else { continue };
|
||||
(p.trim(), c.trim())
|
||||
};
|
||||
let Ok(pid) = pid.parse::<u32>() else { continue };
|
||||
if !cfg!(windows) && !(cmd.contains("igneum-miner ") || cmd.ends_with("igneum-miner")) {
|
||||
continue;
|
||||
}
|
||||
if cmd.contains(" mine ") {
|
||||
v.push((pid, cmd.to_string()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Ends one process by pid (Windows: taskkill /T /F; unix: SIGKILL).
|
||||
pub fn kill_pid(pid: u32) {
|
||||
if cfg!(windows) {
|
||||
let _ = quiet(&mut std::process::Command::new(tool("taskkill"))).args(["/PID", &pid.to_string(), "/T", "/F"]).output();
|
||||
} else {
|
||||
let _ = quiet(&mut std::process::Command::new("kill")).args(["-9", &pid.to_string()]).output();
|
||||
}
|
||||
}
|
||||
|
||||
/// std (what today's launcher does with taskkill /F).
|
||||
pub fn terminate(child: &mut std::process::Child) {
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,46 @@ use std::time::{Duration, Instant};
|
|||
pub const TASK_NAME: &str = "Igneum Power Helper";
|
||||
/// The helper ends after this long without a new command.
|
||||
pub const IDLE_S: u64 = 20 * 60;
|
||||
/// The heartbeat file the helper refreshes every poll; an engine judges "the helper runs" by it, never by a flag
|
||||
/// of its own (6 October 2026, PC 1: the flag said yes after the helper's idle exit, and commands went to nobody).
|
||||
pub const ALIVE_FILE: &str = "helper.alive";
|
||||
/// A heartbeat older than this is a dead helper.
|
||||
pub const ALIVE_MAX_S: u64 = 4;
|
||||
|
||||
/// Writes the heartbeat: the unix time, as text.
|
||||
pub fn beat(dir: &Path, now: u64) {
|
||||
let _ = std::fs::write(dir.join(ALIVE_FILE), now.to_string());
|
||||
}
|
||||
|
||||
/// Reads a heartbeat: is the helper alive at `now`?
|
||||
pub fn alive_at(text: &str, now: u64) -> bool {
|
||||
text.trim().parse::<u64>().map(|t| now.saturating_sub(t) <= ALIVE_MAX_S).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Is the helper that reads `dir` alive now?
|
||||
pub fn alive(dir: &Path) -> bool {
|
||||
std::fs::read_to_string(dir.join(ALIVE_FILE)).map(|t| alive_at(&t, crate::platform::unix_now())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Starts the task unless the helper already runs, then waits for a fresh heartbeat (up to `wait`). The engine
|
||||
/// writes a command only after this says Ok: the helper skips what the file held before its start (a stale quit
|
||||
/// is not a command), so a command written before the start would be skipped with it (6 October 2026, 17:55:25Z
|
||||
/// on PC 1: the first request of the first tune).
|
||||
pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
|
||||
if alive(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
start()?;
|
||||
let until = Instant::now() + wait;
|
||||
while Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
if alive(dir) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("the Igneum Power Helper task gave no heartbeat within {} s of its start", wait.as_secs()))
|
||||
}
|
||||
|
||||
/// One parsed command from cmd.txt.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -79,6 +119,37 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The sequence number a command line carries on the wire. The helper runs a line only when its number is above
|
||||
/// every number it has seen (its `last_seq`, seeded from the file at its start), so every writer must draw from
|
||||
/// ONE monotonic space: the unix time modulo a million (six digits, what `parse_line` accepts), plus a small
|
||||
/// offset per line. (F) 6 October 2026, 22:19Z on PC 1: the tune path wrote its request index ("00 dev 1", "01 pl
|
||||
/// 160", ...) while the cap path had written 305327 and up, so the helper skipped every tune command as stale and
|
||||
/// both climbs stopped on "the helper did not run sequence 1 within 15 s". Wraps every 11.6 days; the helper's
|
||||
/// idle exit (20 minutes) re-seeds it from the file, so a wrap costs at most one tune step.
|
||||
pub fn wire_seq() -> u64 {
|
||||
crate::platform::unix_now() % 999_990
|
||||
}
|
||||
|
||||
/// How many leading lines of `text` are still the ones the helper saw at its start: `skip` while the file only grew
|
||||
/// and its first `skip` lines read as before; 0 when the file shrank OR was rewritten (the same count, other text).
|
||||
/// PC 2, 7 October 2026 (every tune refused since the 14:35Z boot, "helper started" three times with no command run):
|
||||
/// the engine writes its four command lines with fs::write over a stale four-line file, so the count never dropped,
|
||||
/// the skip never reset, and the helper read every new command as "present at start".
|
||||
pub fn effective_skip(initial: &str, text: &str, skip: usize) -> usize {
|
||||
if text.lines().count() < skip {
|
||||
return 0;
|
||||
}
|
||||
let same_prefix = text.lines().take(skip).eq(initial.lines().take(skip));
|
||||
if same_prefix { skip } else { 0 }
|
||||
}
|
||||
|
||||
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
|
||||
/// when the file shrank or was rewritten: effective_skip). A stale `quit` or `remove` from an earlier engine is never a command.
|
||||
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
|
||||
let skip = if text.lines().count() < skip { 0 } else { skip };
|
||||
text.lines().skip(skip).filter_map(parse_line).collect()
|
||||
}
|
||||
|
||||
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
||||
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
||||
match c {
|
||||
|
|
@ -147,9 +218,12 @@ pub fn start_command() -> String {
|
|||
format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0")
|
||||
}
|
||||
|
||||
/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1).
|
||||
/// The PowerShell that says whether the task is registered AND its action's exe is still there (exit 0), or not (exit 1).
|
||||
/// A task whose exe has gone (the stale-task class of 7 October 2026: a logon task pointing at a folder that was not
|
||||
/// there any more) reads as not registered, so the next cap apply with Power control on registers it again through the
|
||||
/// one approved step instead of starting a task that cannot run.
|
||||
pub fn query_command() -> String {
|
||||
format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}")
|
||||
format!("$t = Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue; if ($t -and $t.State -ne 'Disabled' -and (Test-Path (($t.Actions[0].Execute).Trim('\"')))) {{ exit 0 }} else {{ exit 1 }}")
|
||||
}
|
||||
|
||||
/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left.
|
||||
|
|
@ -195,20 +269,29 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
};
|
||||
log("helper started (scheduled task, elevated)");
|
||||
// a stale file from an earlier run is not a command: only lines after the start count
|
||||
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
|
||||
let mut last_text = String::new();
|
||||
beat(dir, crate::platform::unix_now());
|
||||
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
|
||||
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
|
||||
// same second, and every later start did the same; so the lines present at the start are skipped whole
|
||||
// (quit and remove included), and a file that shrinks starts the count again
|
||||
let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
|
||||
let mut skip = initial.lines().count();
|
||||
let initial_text = initial.clone();
|
||||
let mut last_text = initial;
|
||||
let mut dev = "0".to_string();
|
||||
let mut idle = Instant::now();
|
||||
let smi = crate::platform::tool("nvidia-smi");
|
||||
loop {
|
||||
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||
if text != last_text {
|
||||
skip = effective_skip(&initial_text, &text, skip);
|
||||
last_text = text.clone();
|
||||
for (seq, c) in text.lines().filter_map(parse_line) {
|
||||
for (seq, c) in commands_after(&text, skip) {
|
||||
match c {
|
||||
HelperCmd::Quit => {
|
||||
log("quit");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
HelperCmd::Remove => {
|
||||
|
|
@ -261,8 +344,10 @@ pub fn run_helper(dir: &Path) -> i32 {
|
|||
}
|
||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||
return 0;
|
||||
}
|
||||
beat(dir, crate::platform::unix_now());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
|
@ -296,6 +381,32 @@ mod tests {
|
|||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
|
||||
// a stale quit present at the start is skipped; a quit added later counts; a rewritten (shorter) file counts whole
|
||||
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]);
|
||||
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]);
|
||||
assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]);
|
||||
// PC 2, 7 October 2026: the known-failed shape first. A stale four-line file at the helper's start, then the engine's
|
||||
// four-line rewrite: the count never dropped, so the old rule skipped every new command
|
||||
let stale = "401000 dev 0\n401001 pl 460\n401002 rgc\n401003 rmc\n";
|
||||
let fresh = "401888 dev 0\n401889 pl 575\n401890 rgc\n401891 rmc\n";
|
||||
assert_eq!(commands_after(fresh, 4), vec![], "the old rule: a same-length rewrite is invisible");
|
||||
assert_eq!(effective_skip(stale, fresh, 4), 0, "a rewrite resets the skip");
|
||||
assert_eq!(commands_after(fresh, effective_skip(stale, fresh, 4)).len(), 4, "every new command runs");
|
||||
// the file only grew: the stale prefix stays skipped (a stale quit at start is never a command)
|
||||
let grown = format!("{stale}401888 dev 0\n");
|
||||
assert_eq!(effective_skip(stale, &grown, 4), 4);
|
||||
assert_eq!(commands_after(&grown, effective_skip(stale, &grown, 4)), vec![(401888, HelperCmd::Dev("0".into()))]);
|
||||
assert_eq!(effective_skip("quit\n", "quit\n401888 dev 0\n", 1), 1, "a stale quit stays skipped while the file only grows");
|
||||
assert_eq!(effective_skip(stale, "401888 dev 0\n", 4), 0, "a shorter file resets as before");
|
||||
assert_eq!(effective_skip("", fresh, 0), 0);
|
||||
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
|
||||
// (F) every wire number parses (six digits at most) and leaves room for the four lines of a tune step
|
||||
let w = wire_seq();
|
||||
assert!(w + 3 <= 999_999 && parse_line(&format!("{} rmc", w + 3)).is_some());
|
||||
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
|
||||
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
|
||||
assert!(!alive_at("", 1791309325));
|
||||
assert!(!alive_at("soon", 1791309325));
|
||||
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
|
||||
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
|
||||
assert!(readback_command().contains("Actions[0].Execute"));
|
||||
|
|
@ -328,6 +439,14 @@ mod tests {
|
|||
assert!(!s.contains("-Trigger"), "no trigger: only the app starts it");
|
||||
assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}");
|
||||
assert!(s.contains(&format!("-TaskName '{TASK_NAME}'")));
|
||||
// no window at logon or at a start (the project lead, 7 October 2026, PC 2's Terminal window): the action is the app's own exe,
|
||||
// a windows-subsystem program with no console, never powershell.exe, cmd.exe or a `start` of a batch file
|
||||
assert!(s.contains("-Execute 'C:\\p\\igneum-app.exe' -Argument '--power-helper'") || s.contains("-Argument '--power-helper'"), "{s}");
|
||||
assert!(!s.contains("powershell.exe") && !s.contains("cmd.exe") && !s.contains("cmd /c start"), "the task's action must be the exe itself: {s}");
|
||||
assert!(s.contains("-Hidden"), "the task is hidden in the scheduler too");
|
||||
// the registered probe also wants the action's exe on disk and the task enabled (the stale-task class)
|
||||
let q = query_command();
|
||||
assert!(q.contains("Test-Path (($t.Actions[0].Execute).Trim") && q.contains("$t.State -ne 'Disabled'") && q.contains("exit 1"), "{q}");
|
||||
// a quote in the path cannot break out of the literal
|
||||
let q = register_script(Path::new(r"C:\it's\igneum-app.exe"));
|
||||
assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,39 @@ pub const MIN_VRAM_MB_PROVE_ONLY: u64 = 23_552;
|
|||
/// miner), the devnet's shard until its fee switch at DAA 210,000; `nvidia-smi` reports 32,607 for the RTX 5090.
|
||||
pub const VRAM_MB_PROTOTYPE_SHARD: u64 = 31_000;
|
||||
|
||||
/// Main's rule (7 October 2026, the fleet's prover roll): a 10 GB card never completes the compressed step on the
|
||||
/// 0.3.17 pair (p1-3080: 29 of 29 proofs died at the memory wall, device_used 9,859 of 9,885 MiB, 0 paid in 2,167
|
||||
/// claims), so the prover REFUSES to start, Settings or not, unless an NVIDIA card of 12 GB or more is present, with
|
||||
/// the reason shown; the lower-memory SP1 threshold is being measured on a rented 3080 and moves this line when
|
||||
/// it lands. `nvidia-smi` reports a 12 GB card at about 12,208 MiB, a 10 GB card at about 10,240.
|
||||
pub const MIN_VRAM_MB_PROVE_ANY: u64 = 11_800;
|
||||
/// The sentence the Proving tile and the log carry when the rule refuses (verbatim from main; the kit's box-prover
|
||||
/// says the same).
|
||||
pub const PROVE_UNDER_12GB_LINE: &str = "proving needs a 12 GB card; mining continues";
|
||||
|
||||
/// The rule above as one question: None when a present NVIDIA card of 12 GB or more exists (or when no NVIDIA card
|
||||
/// is present at all, since then the CPU and Apple paths decide), Some(the sentence) when every present NVIDIA card is
|
||||
/// under 12 GB.
|
||||
/// The sentence the tile carries while the switch holds the miner off.
|
||||
pub fn prove_instead_line(cards: &[CardState]) -> String {
|
||||
let names: Vec<String> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| format!("{} ({} GB)", c.name, gb(c.vram_mb))).collect();
|
||||
format!("proving instead of mining on {} (a card under 12 GB holds one, not both)", names.join(", "))
|
||||
}
|
||||
|
||||
/// The keys of the cards the switch holds off: every present NVIDIA card under 12 GB, and only when the rule refuses
|
||||
/// (no present NVIDIA card at or above 12 GB); with a bigger card present the small ones keep mining and nothing is held.
|
||||
pub fn prove_instead_cards(cards: &[CardState]) -> Vec<String> {
|
||||
if prove_refused_under_12gb(cards).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
cards.iter().filter(|c| c.vendor == "nvidia" && c.present() && c.vram_mb < MIN_VRAM_MB_PROVE_ANY).map(|c| c.key.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn prove_refused_under_12gb(cards: &[CardState]) -> Option<&'static str> {
|
||||
let nvidia: Vec<&CardState> = cards.iter().filter(|c| c.vendor == "nvidia" && c.present()).collect();
|
||||
if nvidia.is_empty() || nvidia.iter().any(|c| c.vram_mb >= MIN_VRAM_MB_PROVE_ANY) { None } else { Some(PROVE_UNDER_12GB_LINE) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Decision {
|
||||
pub on: bool,
|
||||
|
|
@ -166,4 +199,78 @@ mod tests {
|
|||
let d = decide(&[idle("nvidia", "RTX 4090", 24_564), card("nvidia", "RTX 5090", 32_607)], "linux", None, None);
|
||||
assert!(d.line.contains("RTX 5090 (32 GB, mining too)"), "{}", d.line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prover_refuses_every_nvidia_card_under_12gb_and_says_why() {
|
||||
let c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone()]), Some(PROVE_UNDER_12GB_LINE));
|
||||
// a 12 GB card beside it lifts the refusal (nvidia-smi reports a 12 GB card at 12,208)
|
||||
let c3080_12 = card("nvidia", "NVIDIA GeForce RTX 3080 12GB", 12_208);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3080_12]), None);
|
||||
// an idle 24 GB card lifts it too
|
||||
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080.clone(), c3090]), None);
|
||||
// no NVIDIA card at all: not this rule's question (Apple and AMD have their own sentences)
|
||||
assert_eq!(prove_refused_under_12gb(&[card("amd", "AMD Radeon RX 9070 XT", 16_368), card("apple", "Apple M5 Max", 0)]), None);
|
||||
// a card that left the machine does not count either way
|
||||
let mut gone = card("nvidia", "NVIDIA GeForce RTX 3090", 24_564); gone.removed_at = 1.0;
|
||||
assert_eq!(prove_refused_under_12gb(&[c3080, gone]), Some(PROVE_UNDER_12GB_LINE));
|
||||
assert_eq!(PROVE_UNDER_12GB_LINE, "proving needs a 12 GB card; mining continues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prove_instead_holds_only_the_small_cards_and_only_when_the_rule_refuses() {
|
||||
let mut c3080 = card("nvidia", "NVIDIA GeForce RTX 3080", 10_240);
|
||||
c3080.key = "nvidia:0:NVIDIA GeForce RTX 3080".into();
|
||||
assert_eq!(prove_instead_cards(&[c3080.clone()]), vec!["nvidia:0:NVIDIA GeForce RTX 3080".to_string()]);
|
||||
assert_eq!(prove_instead_line(&[c3080.clone()]), "proving instead of mining on NVIDIA GeForce RTX 3080 (10 GB) (a card under 12 GB holds one, not both)");
|
||||
let c3090 = idle("nvidia", "NVIDIA GeForce RTX 3090", 24_564);
|
||||
assert!(prove_instead_cards(&[c3080.clone(), c3090]).is_empty());
|
||||
assert!(prove_instead_cards(&[card("amd", "AMD Radeon RX 9070 XT", 16_368)]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// MF-10 (docs/plans/miner-faults.md, 7 October 2026): a `sp1-gpu-server` built for another card's architecture fails
|
||||
/// every proof in 12 s with `CudaRustError: named symbol not found` and nothing notices. `card_cap` is the card's
|
||||
/// compute capability as nvidia-smi prints it ("12.0", "8.9", "8.6"); `server_archs` are the `sm_NN` words found in
|
||||
/// the server binary (empty = unknown, which passes: an SDK server may carry no arch string). Some(the sentence) when
|
||||
/// the server names architectures and none is the card's.
|
||||
pub fn server_mismatch(card_cap: &str, server_archs: &[String]) -> Option<String> {
|
||||
let cap = card_cap.trim();
|
||||
if cap.is_empty() || server_archs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let want = format!("sm_{}", cap.replace('.', ""));
|
||||
if server_archs.iter().any(|a| a.trim() == want) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("the proving server is built for {} and this card is {want} (compute capability {cap}); proving stays off here until a server for this card is installed", server_archs.join(", ")))
|
||||
}
|
||||
|
||||
/// A proof failure line that names the architecture class (MF-10): the server's kernels do not load on this card.
|
||||
pub fn is_arch_failure(text: &str) -> bool {
|
||||
text.contains("named symbol not found") || text.contains("no kernel image is available")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arch_tests {
|
||||
use super::*;
|
||||
|
||||
/// The prover roll, 7 October 2026: sm_86 servers on a 4070 (8.9) and a 5090 (12.0) failed every proof; the
|
||||
/// 3080 and 3090 (8.6) proved.
|
||||
#[test]
|
||||
fn a_server_for_another_card_is_refused() {
|
||||
let sm86 = vec!["sm_86".to_string()];
|
||||
assert!(server_mismatch("8.9", &sm86).unwrap().contains("built for sm_86 and this card is sm_89"));
|
||||
assert!(server_mismatch("12.0", &sm86).unwrap().contains("sm_120"));
|
||||
assert_eq!(server_mismatch("8.6", &sm86), None);
|
||||
// a fat binary names every card
|
||||
let fat = vec!["sm_86".to_string(), "sm_89".to_string(), "sm_120".to_string()];
|
||||
assert_eq!(server_mismatch("8.9", &fat), None);
|
||||
// unknown on either side passes (the proof failure line is the second guard)
|
||||
assert_eq!(server_mismatch("", &sm86), None);
|
||||
assert_eq!(server_mismatch("8.9", &[]), None);
|
||||
assert!(is_arch_failure("CudaRustError: named symbol not found"));
|
||||
assert!(!is_arch_failure("PermissionDenied"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,22 @@ struct Tools {
|
|||
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
|
||||
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
|
||||
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
|
||||
/// The exporter's failure line. When the node's export carries the account dump (0.3.14) and the exporter's output
|
||||
/// never mentions it, the exporter at `path` predates 0.3.14: it replays the dump's own segment from the restart
|
||||
/// state and fails there with "port state root differs" (PC 1, 6 October 2026 18:16Z, block 140,662: the installer
|
||||
/// had not replaced igneum-prove-export); the line names the stale binary instead of the misleading root error.
|
||||
fn exporter_failure(export_has_dump: bool, out: &str, path: &Path, block: Option<u64>) -> String {
|
||||
let last = out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed");
|
||||
let what = match block {
|
||||
Some(b) => format!("exporter, block {b}"),
|
||||
None => "exporter".to_string(),
|
||||
};
|
||||
if export_has_dump && !out.contains("account dump") {
|
||||
return format!("{what}: stale igneum-prove-export at {}: the node's export carries the account dump and this exporter does not read it (it predates 0.3.14); install this release's exporter there ({last})", path.display());
|
||||
}
|
||||
format!("{what}: {last}")
|
||||
}
|
||||
|
||||
/// state: unprovable on every node).
|
||||
fn exec_boundary(shared: &Shared) -> u64 {
|
||||
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||
|
|
@ -151,23 +167,9 @@ fn exec_boundary(shared: &Shared) -> u64 {
|
|||
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
|
||||
None,
|
||||
timeout + Duration::from_secs(2),
|
||||
);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||
crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout)
|
||||
}
|
||||
|
||||
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
|
||||
|
|
@ -382,6 +384,8 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
read_ids(&shared, &t);
|
||||
}
|
||||
}
|
||||
// MF-10: the server architecture check, once per tools probe (None = not read yet; Some(None) = matches)
|
||||
let mut arch_check: Option<Option<String>> = None;
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
let enabled = shared.settings.lock().unwrap().prove;
|
||||
|
|
@ -451,6 +455,55 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// main's rule (7 October 2026, the fleet's prover roll): a 10 GB card dies at the compressed step every time
|
||||
// (p1-3080, 29 of 29, 0 paid), so with every present NVIDIA card under 12 GB the prover refuses to start,
|
||||
// Settings or not, and says why; the measured threshold moves the line (src/provedefault.rs)
|
||||
if t.cuda {
|
||||
let cards = shared.state.lock().unwrap().mining.cards.clone();
|
||||
match crate::provedefault::prove_refused_under_12gb(&cards) {
|
||||
Some(line) => {
|
||||
// settings.prove_instead (main's routing): the owner chose the prover over the miner on this card; the
|
||||
// engine holds the small cards' miners off while the prover runs and gives them back when it stops
|
||||
let instead = shared.settings.lock().unwrap().prove_instead;
|
||||
set(&shared, |p| p.under_12gb = true);
|
||||
if instead {
|
||||
shared.send(crate::engine::Cmd::ProveHold(true));
|
||||
let msg = crate::provedefault::prove_instead_line(&cards);
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
p.message = msg;
|
||||
});
|
||||
} else {
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = false;
|
||||
p.status = "off".into();
|
||||
p.message = line.into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => set(&shared, |p| p.under_12gb = false),
|
||||
}
|
||||
}
|
||||
// MF-10 (7 October 2026, the prover roll): a GPU server built for another card's architecture fails every
|
||||
// proof; the card's compute capability and the server's sm_ words are read once per tools probe, a mismatch
|
||||
// refuses the GPU path with the reason on the tile
|
||||
if t.cuda {
|
||||
if arch_check.is_none() {
|
||||
arch_check = Some(server_arch_check(&shared, t));
|
||||
}
|
||||
if let Some(Some(line)) = arch_check.as_ref() {
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = false;
|
||||
p.status = "off".into();
|
||||
p.message = line.clone();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
|
|
@ -462,6 +515,16 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
});
|
||||
continue;
|
||||
}
|
||||
// ledger N7 (7 October 2026, PC 1 on 0.3.18): a node before the exec RPC fix dies on igneum_getAssignedShards
|
||||
// and igneum_getProofRecords when its exec follower holds no record (rpc.rs slices records[1..=0]), and the
|
||||
// node reads synced seconds before a slow follower has one; nothing below asks until the follower reports a tip
|
||||
if !crate::update::exec_has_record(shared.runtime.evm_port()) {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = "waiting for the node's execution layer".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 1. the keys and the work list
|
||||
let labels = labels(&shared);
|
||||
let mut keys: Vec<(String, String)> = Vec::new();
|
||||
|
|
@ -499,6 +562,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
if paid {
|
||||
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
||||
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
|
||||
shared.ladder_shard_paid(n, s as u64, wei);
|
||||
set(&shared, |p| {
|
||||
p.paid += 1;
|
||||
p.paid_wei += wei;
|
||||
|
|
@ -665,7 +729,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
|
||||
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, None));
|
||||
}
|
||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
|
||||
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
||||
|
|
@ -674,7 +738,10 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
let last = out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed").to_string();
|
||||
// the root-socket class (5 October 2026, PC 2 at 20:00Z and 21:25Z): a job that ran the host as root
|
||||
// inside WSL2 left /tmp/sp1-cuda-0.sock owned by root, and this user's client cannot open it
|
||||
let hint = if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
|
||||
let hint = if crate::provedefault::is_arch_failure(&last) { " (MF-10: the proving server is built for another card's architecture; proving stays off here until a server for this card is installed)" } else if last.contains("PermissionDenied") { " (a GPU-server socket /tmp/sp1-cuda-*.sock owned by another user, left by a job that ran the prover as root: remove it as that user, or run the socket-fix job)" } else { "" };
|
||||
if crate::provedefault::is_arch_failure(&last) {
|
||||
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, last.replace('"', "'")));
|
||||
}
|
||||
return Err(format!("prover: {last}{hint}"));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
|
|
@ -854,7 +921,7 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
|
|||
let fixture = dir.join(format!("block-{b}.json"));
|
||||
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, Some(b)));
|
||||
}
|
||||
fixtures.push(as_host_path(&fixture));
|
||||
}
|
||||
|
|
@ -1092,6 +1159,21 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_stale_exporter_is_named_when_the_export_carries_the_dump() {
|
||||
let p = Path::new("C:/x/igneum-prove-export.exe");
|
||||
let old_out = "Error: segment 140661: port state root 0xe45c differs from the node's 0xddd7; the port is not the node's executor, stop\n";
|
||||
let line = exporter_failure(true, old_out, p, Some(140662));
|
||||
assert!(line.contains("stale igneum-prove-export at C:/x/igneum-prove-export.exe"), "{line}");
|
||||
assert!(line.starts_with("exporter, block 140662:"));
|
||||
// the 0.3.14 exporter read the dump and failed later: the real line, not the stale one
|
||||
let new_out = "account dump: 79 accounts after chain block 140661, state root 0x1 (equals the node's)\nError: segment 140662: port state root 0x2 differs from the node's 0x3\n";
|
||||
let line = exporter_failure(true, new_out, p, None);
|
||||
assert_eq!(line, "exporter: Error: segment 140662: port state root 0x2 differs from the node's 0x3");
|
||||
// an export without a dump (an older node): never the stale line
|
||||
assert_eq!(exporter_failure(false, old_out, p, None), format!("exporter: {}", old_out.trim()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
||||
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
||||
|
|
@ -1132,3 +1214,23 @@ mod tests {
|
|||
assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer"));
|
||||
}
|
||||
}
|
||||
|
||||
/// MF-10: the card's compute capability and the GPU server's architecture words, read through the same shell the
|
||||
/// tools live in (WSL2 on Windows). None = no mismatch (or nothing readable); Some(line) = refuse with this reason.
|
||||
fn server_arch_check(shared: &Shared, t: &Tools) -> Option<String> {
|
||||
let script = "cap=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null | head -1); srv=\"$HOME/.sp1/bin/sp1-gpu-server\"; archs=$( [ -f \"$srv\" ] && strings \"$srv\" 2>/dev/null | grep -o 'sm_[0-9]*' | sort -u | tr '\\n' ' '); echo \"CAP=$cap\"; echo \"ARCHS=$archs\"";
|
||||
let out = if t.wsl {
|
||||
let file = crate::wslhost::write_script("prove-arch", script).ok()?;
|
||||
crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string())?
|
||||
} else {
|
||||
crate::detect::run_timeout(std::process::Command::new("bash").args(["-c", script]), None, Duration::from_secs(20))?
|
||||
};
|
||||
let cap = out.lines().find_map(|l| l.strip_prefix("CAP=")).unwrap_or("").trim().to_string();
|
||||
let archs: Vec<String> = out.lines().find_map(|l| l.strip_prefix("ARCHS=")).unwrap_or("").split_whitespace().map(|s| s.to_string()).collect();
|
||||
shared.log(&format!("prover: card compute capability {}, server architectures {}", if cap.is_empty() { "unknown" } else { &cap }, if archs.is_empty() { "unknown".to_string() } else { archs.join(" ") }));
|
||||
let line = crate::provedefault::server_mismatch(&cap, &archs);
|
||||
if let Some(l) = &line {
|
||||
shared.log(&format!("FAULT class=prover-arch card=\"gpu\" app={} reason=\"{}\"", crate::engine::VERSION, l.replace('"', "'")));
|
||||
}
|
||||
line
|
||||
}
|
||||
|
|
|
|||
178
app/igneum-app/src/provingdir.rs
Normal file
178
app/igneum-app/src/provingdir.rs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
//! The prover's working directory (`<app dir>/proving`) owns its disk (0.3.16, 6 October 2026): the fleet's segment
|
||||
//! exports (50 to 500 MB each with the old [0, last] export, never pruned) filled 60 GB on the hub and 3 to 46 GB on
|
||||
//! every standing box, and the hub's node died three times on "No space left on device" (the last at 21:42 UK).
|
||||
//! Rules: a size cap and an age cap on the directory (defaults that fit a 100 GB box for a week), enforced before
|
||||
//! every export; a segment's directory is deleted the moment its record is submitted or paid; no export starts
|
||||
//! below 10% free disk, and the skip is logged.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Defaults: 20 GB and 7 days. `IGNEUM_PROVING_DIR_MAX_GB` and `IGNEUM_PROVING_DIR_MAX_DAYS` change them.
|
||||
pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024 * 1024;
|
||||
pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
/// No export below this share of free disk.
|
||||
pub const MIN_FREE_FRACTION: f64 = 0.10;
|
||||
|
||||
pub fn max_bytes() -> u64 {
|
||||
std::env::var("IGNEUM_PROVING_DIR_MAX_GB").ok().and_then(|v| v.parse::<u64>().ok()).map(|g| g * 1024 * 1024 * 1024).unwrap_or(DEFAULT_MAX_BYTES)
|
||||
}
|
||||
|
||||
pub fn max_age() -> Duration {
|
||||
std::env::var("IGNEUM_PROVING_DIR_MAX_DAYS").ok().and_then(|v| v.parse::<u64>().ok()).map(|d| Duration::from_secs(d * 24 * 3600)).unwrap_or(DEFAULT_MAX_AGE)
|
||||
}
|
||||
|
||||
/// One entry of the directory as the planner sees it: a top-level file or a segment directory, its total bytes
|
||||
/// and its age.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub path: PathBuf,
|
||||
pub bytes: u64,
|
||||
pub age: Duration,
|
||||
}
|
||||
|
||||
/// What to delete so the directory fits: everything older than `max_age`, then the oldest entries until the total
|
||||
/// is at or under `max_bytes`. Pure, so the cap is unit-tested.
|
||||
pub fn prune_plan(entries: &[Entry], max_bytes: u64, max_age: Duration) -> Vec<PathBuf> {
|
||||
let mut keep: Vec<&Entry> = Vec::new();
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for e in entries {
|
||||
if e.age > max_age {
|
||||
out.push(e.path.clone());
|
||||
} else {
|
||||
keep.push(e);
|
||||
}
|
||||
}
|
||||
let mut total: u64 = keep.iter().map(|e| e.bytes).sum();
|
||||
// oldest first
|
||||
keep.sort_by(|a, b| b.age.cmp(&a.age));
|
||||
for e in keep {
|
||||
if total <= max_bytes {
|
||||
break;
|
||||
}
|
||||
total = total.saturating_sub(e.bytes);
|
||||
out.push(e.path.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dir_bytes(p: &Path) -> u64 {
|
||||
let mut total = 0;
|
||||
if let Ok(rd) = std::fs::read_dir(p) {
|
||||
for e in rd.flatten() {
|
||||
let m = match e.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
total += if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// The directory's entries, oldest by modification time.
|
||||
pub fn scan(dir: &Path) -> Vec<Entry> {
|
||||
let now = SystemTime::now();
|
||||
let mut out = Vec::new();
|
||||
if let Ok(rd) = std::fs::read_dir(dir) {
|
||||
for e in rd.flatten() {
|
||||
let Ok(m) = e.metadata() else { continue };
|
||||
let age = m.modified().ok().and_then(|t| now.duration_since(t).ok()).unwrap_or_default();
|
||||
let bytes = if m.is_dir() { dir_bytes(&e.path()) } else { m.len() };
|
||||
out.push(Entry { path: e.path(), bytes, age });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Enforces the caps on `dir`; returns (entries deleted, bytes freed).
|
||||
pub fn enforce(dir: &Path) -> (usize, u64) {
|
||||
let entries = scan(dir);
|
||||
let plan = prune_plan(&entries, max_bytes(), max_age());
|
||||
let mut freed = 0;
|
||||
for p in &plan {
|
||||
if let Some(e) = entries.iter().find(|e| &e.path == p) {
|
||||
freed += e.bytes;
|
||||
}
|
||||
let _ = if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) };
|
||||
}
|
||||
(plan.len(), freed)
|
||||
}
|
||||
|
||||
/// Free disk as a share of the volume `path` is on; None when the platform call fails.
|
||||
#[cfg(unix)]
|
||||
pub fn free_fraction(path: &Path) -> Option<f64> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let c = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 {
|
||||
return None;
|
||||
}
|
||||
let total = st.f_blocks as f64 * st.f_frsize as f64;
|
||||
if total <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(st.f_bavail as f64 * st.f_frsize as f64 / total)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn free_fraction(path: &Path) -> Option<f64> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn GetDiskFreeSpaceExW(dir: *const u16, avail: *mut u64, total: *mut u64, free: *mut u64) -> i32;
|
||||
}
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||
let (mut avail, mut total, mut free) = (0u64, 0u64, 0u64);
|
||||
if unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut avail, &mut total, &mut free) } == 0 || total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(avail as f64 / total as f64)
|
||||
}
|
||||
|
||||
/// The pre-export gate: the caps enforced, then the free-disk check. Err carries the line to log when the export
|
||||
/// must be skipped.
|
||||
pub fn before_export(dir: &Path) -> Result<(), String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let (n, freed) = enforce(dir);
|
||||
if n > 0 {
|
||||
eprintln!("prover: proving dir cap: {n} entries deleted, {} MB freed", freed / (1024 * 1024));
|
||||
}
|
||||
match free_fraction(dir) {
|
||||
Some(f) if f < MIN_FREE_FRACTION => Err(format!("no export: {:.1}% of the disk is free, under the {:.0}% floor; the proving dir is {} MB after its cap; free space or lower IGNEUM_PROVING_DIR_MAX_GB", f * 100.0, MIN_FREE_FRACTION * 100.0, dir_bytes(dir) / (1024 * 1024))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment's directory goes the moment its record is submitted or paid.
|
||||
pub fn remove_segment(dir: &Path, first: u64) {
|
||||
let _ = std::fs::remove_dir_all(dir.join(format!("seg-{first}")));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn e(name: &str, mb: u64, days: u64) -> Entry {
|
||||
Entry { path: PathBuf::from(name), bytes: mb * 1024 * 1024, age: Duration::from_secs(days * 24 * 3600) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cap_deletes_the_old_then_the_oldest_until_it_fits() {
|
||||
let entries = vec![e("seg-1", 400, 9), e("seg-2", 300, 3), e("seg-3", 300, 2), e("seg-4", 200, 1), e("block-5.json", 1, 0)];
|
||||
// the age cap takes seg-1; the size cap (600 MB) then takes seg-2 (oldest kept), leaving 501 MB
|
||||
let plan = prune_plan(&entries, 600 * 1024 * 1024, Duration::from_secs(7 * 24 * 3600));
|
||||
assert_eq!(plan, vec![PathBuf::from("seg-1"), PathBuf::from("seg-2")]);
|
||||
// under both caps: nothing
|
||||
assert!(prune_plan(&entries[1..], 2 * 1024 * 1024 * 1024, Duration::from_secs(30 * 24 * 3600)).is_empty());
|
||||
// a 100 GB box for a week: the default caps leave 80 GB to the node and the system
|
||||
assert_eq!(DEFAULT_MAX_BYTES, 20 * 1024 * 1024 * 1024);
|
||||
assert_eq!(DEFAULT_MAX_AGE, Duration::from_secs(7 * 24 * 3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_free_check_answers_on_this_machine() {
|
||||
let f = free_fraction(Path::new(".")).expect("statvfs");
|
||||
assert!((0.0..=1.0).contains(&f));
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,9 @@ const INDEX: &str = include_str!("../ui/index.html");
|
|||
const CSS: &str = include_str!("../ui/app.css");
|
||||
const JS: &str = include_str!("../ui/app.js");
|
||||
const MARK: &str = include_str!("../ui/mark.svg");
|
||||
const DAG_JS: &str = include_str!("../ui/live-dag.js");
|
||||
/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5)
|
||||
const PROOF_JS: &str = include_str!("../ui/proof-core.js");
|
||||
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
||||
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
||||
|
|
@ -138,6 +141,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) {
|
|||
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
|
||||
}
|
||||
|
||||
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.png`, the name reduced to [a-z0-9-] (at most
|
||||
/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder.
|
||||
pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf {
|
||||
let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect();
|
||||
let safe = if safe.is_empty() { "shot".to_string() } else { safe };
|
||||
data_root.join("shots").join(format!("igneum-{safe}-{unix}.png"))
|
||||
}
|
||||
|
||||
|
||||
fn query_param(q: &str, key: &str) -> Option<String> {
|
||||
q.split('&').find_map(|kv| {
|
||||
let (k, v) = kv.split_once('=')?;
|
||||
|
|
@ -160,11 +172,31 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
return;
|
||||
};
|
||||
let rest = if rest.is_empty() { "/" } else { rest };
|
||||
// ui-ota (src/uiota.rs): an active interface bundle serves its files in place of the embedded ones; the names are
|
||||
// fixed (uiota::SERVED), nothing else is read from the folder; a file the bundle lacks falls back to the embedded one
|
||||
if req.method == "GET" {
|
||||
let rel = if rest == "/" { "index.html" } else { rest.trim_start_matches('/') };
|
||||
if crate::uiota::SERVED.contains(&rel) {
|
||||
if let Some(dir) = shared.ui_dir() {
|
||||
if let Ok(bytes) = std::fs::read(dir.join(rel)) {
|
||||
if rel == "index.html" {
|
||||
let v = std::fs::read_to_string(dir.join("VERSION")).unwrap_or_default().trim().to_string();
|
||||
shared.send(Cmd::UiPageLoaded(v));
|
||||
}
|
||||
respond(&mut stream, 200, crate::uiota::content_type(rel), &bytes, rel.starts_with("fonts/"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match (req.method.as_str(), rest) {
|
||||
("GET", "/") | ("GET", "/index.html") => respond(&mut stream, 200, "text/html; charset=utf-8", INDEX.as_bytes(), false),
|
||||
("GET", "/VERSION") => respond(&mut stream, 200, "text/plain; charset=utf-8", crate::uiota::EMBEDDED_VERSION.as_bytes(), false),
|
||||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
|
||||
("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false),
|
||||
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
|
||||
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
|
||||
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
|
||||
|
|
@ -178,6 +210,20 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
let v = shared.state_json();
|
||||
json_resp(&mut stream, 200, v);
|
||||
}
|
||||
// the chain scene's feed (src/live.rs): the observer's /api/live with this machine's lane as "you", cached 2 s
|
||||
("GET", "/api/live") => {
|
||||
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
|
||||
}
|
||||
// miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local
|
||||
// node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s
|
||||
("GET", "/api/ladder") => {
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: Vec<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids));
|
||||
}
|
||||
("GET", "/api/log") => {
|
||||
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
|
||||
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
|
||||
|
|
@ -256,9 +302,50 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
|
||||
let climb = body.get("climb").and_then(|v| v.as_bool());
|
||||
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
|
||||
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
|
||||
if let Some(k) = key {
|
||||
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
|
||||
shared.send(Cmd::TuneCardGoal(k, g));
|
||||
return Ok(json!({ "ok": true }));
|
||||
}
|
||||
shared.send(Cmd::TuneGoal(goal, price, climb));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/region" => {
|
||||
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
|
||||
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
|
||||
// hundredths of the chosen unit per kWh: up to 100,000 so a zero-decimal currency (yen, won) fits
|
||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=100_000.0).contains(p));
|
||||
let currency = body.get("currency").and_then(|v| v.as_str()).map(|c| c.to_string());
|
||||
shared.send(Cmd::Region(region, price, currency));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/heat" => {
|
||||
// Ember Heat: the switch, the set point, the schedule with the window's clock offset, a typed room reading
|
||||
let patch = crate::engine::HeatPatch {
|
||||
on: body.get("on").and_then(|v| v.as_bool()),
|
||||
set_c: body.get("set_c").and_then(|v| v.as_f64()),
|
||||
schedule: body.get("schedule").and_then(|v| v.as_str()).map(|t| (t.to_string(), body.get("tz_min").and_then(|v| v.as_i64()).unwrap_or(0).clamp(-840, 840) as i32)),
|
||||
room_c: body.get("room_c").and_then(|v| v.as_f64()),
|
||||
};
|
||||
if let Some(c) = patch.set_c {
|
||||
if !(crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
|
||||
return Err(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
|
||||
}
|
||||
}
|
||||
if let Some((t, _)) = &patch.schedule {
|
||||
crate::heat::parse_schedule(t)?;
|
||||
}
|
||||
if let Some(c) = patch.room_c {
|
||||
if !(-20.0..=50.0).contains(&c) {
|
||||
return Err("a room reading is -20 to 50 degrees".into());
|
||||
}
|
||||
}
|
||||
shared.send(Cmd::Heat(patch));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/settings" => {
|
||||
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
|
||||
let vote = body.get("vote").and_then(|v| v.as_bool());
|
||||
|
|
@ -267,10 +354,24 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let display_name = s("display_name");
|
||||
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
|
||||
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
|
||||
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
|
||||
}
|
||||
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||
"/api/prove/instead" => shared.set_prove_instead(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||
"/api/prove/setup" => crate::prover::setup(shared),
|
||||
"/api/drivers/install" => {
|
||||
let vendor = body.get("vendor").and_then(|v| v.as_str()).unwrap_or("").to_ascii_lowercase();
|
||||
if !["nvidia", "amd", "intel"].contains(&vendor.as_str()) {
|
||||
return Err("vendor must be nvidia, amd or intel".into());
|
||||
}
|
||||
shared.send(Cmd::DriverInstall(vendor));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/drivers/restart" => {
|
||||
shared.send(Cmd::DriverRestart);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/check" => {
|
||||
shared.send(Cmd::CheckUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
|
|
@ -279,6 +380,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::InstallUpdate);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/health" => {
|
||||
// ui-ota: the page's first-paint ping (no error) or the error it caught before it (src/uiota.rs)
|
||||
let err = body.get("error").and_then(|v| v.as_str()).filter(|e| !e.is_empty()).map(|e| e.to_string());
|
||||
shared.send(Cmd::UiHealth(err));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/ui/builtin" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::UiBuiltin(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/update/auto" => {
|
||||
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
|
||||
shared.send(Cmd::AutoUpdate(on));
|
||||
|
|
@ -351,6 +463,38 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::ClockCheck);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
|
||||
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
|
||||
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
|
||||
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
|
||||
// miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under
|
||||
// <data root>/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}.
|
||||
// first-block-21: the page showed (or the user dismissed) the block card for the milestone at {count, at};
|
||||
// the card is spent for later runs and a first-block card sets the lifetime flag (src/ladder.rs card_seen)
|
||||
"/api/card/seen" => {
|
||||
let count = body.get("count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let at = body.get("at").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let changed = shared.ladder.lock().unwrap().card_seen(count, at);
|
||||
if changed { shared.save_ladder(); }
|
||||
Ok(json!({ "ok": true, "changed": changed }))
|
||||
}
|
||||
"/api/card" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block");
|
||||
let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?;
|
||||
let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?;
|
||||
let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||
std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?;
|
||||
if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); }
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() }))
|
||||
}
|
||||
"/api/shot" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||
println!("SHOT {}", path.display());
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
|
||||
}
|
||||
"/api/quit" => {
|
||||
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
|
||||
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
|
||||
|
|
@ -359,3 +503,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
_ => Err("unknown api".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_shot_lands_under_the_data_root_with_a_safe_name() {
|
||||
let root = std::path::Path::new("/tmp/igneum-data");
|
||||
assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png"));
|
||||
assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png"));
|
||||
assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png"));
|
||||
assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png"));
|
||||
assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,29 @@ pub struct NodeState {
|
|||
pub version: String,
|
||||
pub last_reading_age_s: f64,
|
||||
pub message: String,
|
||||
/// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's
|
||||
/// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120.
|
||||
pub tip_age_s: f64,
|
||||
/// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another
|
||||
/// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules
|
||||
/// or another network, so no node is read), "" before the first start
|
||||
pub source: String,
|
||||
/// for an external node: match | mismatch | unknown (an older node that cannot answer the check)
|
||||
pub rules_check: String,
|
||||
/// the one line that says what happened at the ports, kept for the Node details
|
||||
pub port_note: String,
|
||||
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
|
||||
pub digest_source: String,
|
||||
/// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports),
|
||||
/// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other
|
||||
/// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports.
|
||||
pub mode: String,
|
||||
/// why the mode is what it is, one line
|
||||
pub mode_reason: String,
|
||||
/// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
|
||||
pub sync_cause: String,
|
||||
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node
|
||||
pub stall_exits: u32,
|
||||
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
||||
pub consensus_switch_daa: u64,
|
||||
pub override_restart_wait: String,
|
||||
|
|
@ -54,7 +77,7 @@ pub struct CardState {
|
|||
pub detail: String, // memory, cores
|
||||
pub device: String, // the worker's --device value (Windows)
|
||||
pub enabled: bool,
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed (no "faulted": no fault is permanent, 7 October 2026) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub hash_now: f64, // MH/s, the last interval
|
||||
pub hash_avg: f64, // MH/s since the start
|
||||
pub accepted: u64,
|
||||
|
|
@ -121,11 +144,19 @@ pub struct CardState {
|
|||
pub mem_cap_mhz: u32, // Ember 2: the memory clock set by the tune (0 = the driver's default)
|
||||
pub amd_ordinal: i64, // the `amd N` ordinal of igneum-gpu-telemetry (-1 = unknown)
|
||||
pub driver: String, // the driver version (nvidia-smi, or the worker's race line)
|
||||
/// driver-check (7 October 2026): the comparable version the OS reports (nvidia-smi's for NVIDIA, Windows'
|
||||
/// DriverVersion for AMD and Intel; empty = none found) and what the row says about it against the manifest's table
|
||||
pub driver_os: String,
|
||||
pub driver_offer: Option<crate::drivers::Offer>,
|
||||
pub program_class: String, // the program class of the race line (loads and wide loads per hash); "" = unknown
|
||||
pub tune_control: bool, // both knobs reach the card (else measure only; sweep_note says why)
|
||||
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
||||
pub tune_source: String, // full | confirm | baseline
|
||||
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
||||
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
|
||||
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
|
||||
pub tune_before_mhs: f64,
|
||||
pub tune_floor: bool, // the chosen clock is the ladder's floor (the row's sub-line says so)
|
||||
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
||||
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
||||
pub tune_step: u32,
|
||||
|
|
@ -163,6 +194,10 @@ pub struct MiningState {
|
|||
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
|
||||
pub fee_session: u64,
|
||||
pub fee_total: u64,
|
||||
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
|
||||
/// £ a day at settings.power_price_pence (0 when no price is set)
|
||||
pub watts_total: f64,
|
||||
pub pounds_per_day: f64,
|
||||
}
|
||||
|
||||
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
|
||||
|
|
@ -188,6 +223,8 @@ pub struct ProgramState {
|
|||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct ProvingState {
|
||||
pub enabled: bool,
|
||||
/// every present NVIDIA card is under 12 GB: the prover refuses unless settings.prove_instead holds the miner off
|
||||
pub under_12gb: bool,
|
||||
/// the host runs here (macOS, Linux) or inside WSL2 (Windows); false = Set up needed
|
||||
pub available: bool,
|
||||
pub setup_hint: String,
|
||||
|
|
@ -249,6 +286,23 @@ pub struct FinalityState {
|
|||
pub age_s: f64,
|
||||
pub votes: u64,
|
||||
pub message: String,
|
||||
/// Horizon polish Q83/Q84 (6 October 2026): the network's finality is paused (a synced node, no checkpoint lock
|
||||
/// for manifest::FINALITY_PAUSE_S); since when (the last lock's time, else the engine's start); the cause when
|
||||
/// the node carries it (its `finality_reason=` / `held_by=` line), else the plain two-thirds line; and the one
|
||||
/// sentence every surface shows: "Finality paused since 18:39 UTC: under two thirds of the weight is signing"
|
||||
pub paused: bool,
|
||||
pub paused_since: f64,
|
||||
pub reason: String,
|
||||
pub held_by: String,
|
||||
pub line: String,
|
||||
/// the node's word (igneum_getProvingStatus finalityProvisional, 0.3.16): locks are provisional right now
|
||||
pub provisional: bool,
|
||||
/// where the cause came from: "node" (the RPC's structured fields), "node-line" (its log line) or "" (the
|
||||
/// engine's own rule)
|
||||
pub cause_source: String,
|
||||
/// the node's own finalityActive (b2e21447), when it carries it: then the node decides `paused`, not the
|
||||
/// engine's 15-minute rule
|
||||
pub node_active: Option<bool>,
|
||||
}
|
||||
|
||||
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
|
||||
|
|
@ -271,6 +325,15 @@ pub struct AddressState {
|
|||
pub source: String,
|
||||
pub key_saved: bool,
|
||||
pub wallet_file: String,
|
||||
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
|
||||
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
|
||||
pub balance_wei: Option<String>,
|
||||
pub balance_age_s: f64,
|
||||
pub balance_note: String, // the last read's error, in words; "" when the last read was good
|
||||
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
|
||||
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
|
||||
/// price on its own
|
||||
pub price_gbp_per_ign: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
@ -297,6 +360,18 @@ pub struct SettingsState {
|
|||
pub tune_goal: String,
|
||||
pub power_price_pence: f64,
|
||||
pub tune_climb: bool,
|
||||
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
|
||||
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
|
||||
pub region: String,
|
||||
/// currency-21: the ISO 4217 override from the settings file ("" = follow the region)
|
||||
pub currency: String,
|
||||
pub heat_on: bool,
|
||||
pub heat_set_c: f64,
|
||||
pub heat_schedule: String,
|
||||
pub heat_tz_min: i32,
|
||||
pub heat_room_c: f64,
|
||||
pub heat_room_at: f64,
|
||||
pub heat_offset_c: f64,
|
||||
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
|
||||
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
|
||||
pub tune_period_s: u64,
|
||||
|
|
@ -304,6 +379,67 @@ pub struct SettingsState {
|
|||
pub dev_fee: bool,
|
||||
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
||||
pub proof_verify_trust: bool,
|
||||
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
|
||||
pub profile_public: bool,
|
||||
/// ui-ota: "Use the built-in interface" (settings.ui_builtin)
|
||||
pub ui_builtin: bool,
|
||||
/// prove instead of mining on a card under 12 GB (settings.prove_instead)
|
||||
pub prove_instead: bool,
|
||||
}
|
||||
|
||||
/// The interface over the air (src/uiota.rs): what serves, from where, since when; Settings > Interface.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct UiState {
|
||||
/// the version compiled into this engine (ui/VERSION)
|
||||
pub embedded_version: String,
|
||||
/// the version the server serves now (the bundle's, or the embedded one)
|
||||
pub active_version: String,
|
||||
/// embedded | ota
|
||||
pub source: String,
|
||||
/// unix s the active bundle was swapped in (0 for the embedded interface)
|
||||
pub installed_at: f64,
|
||||
/// the page confirmed the active bundle with its health ping (always true for the embedded interface)
|
||||
pub confirmed: bool,
|
||||
/// the version the manifest publishes now ("" when the channel is withdrawn)
|
||||
pub published_version: String,
|
||||
pub busy: bool,
|
||||
pub error: String,
|
||||
/// versions refused here (never applied again)
|
||||
pub bad: Vec<String>,
|
||||
pub builtin: bool,
|
||||
/// why the published version is not applied, when it is not
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// Ember Heat (src/heat.rs): what the loop is doing, for the Cards strip, the Settings line and the Overview button.
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct HeatState {
|
||||
pub on: bool,
|
||||
/// off | waiting | paused | heating | resting
|
||||
pub phase: String,
|
||||
/// the set point in force now (the schedule's slot, or the one set point)
|
||||
pub set_c: f64,
|
||||
/// the room estimate and where it came from: typed | card | none; the stated error; the reading's age
|
||||
pub room_c: f64,
|
||||
pub room_source: String,
|
||||
pub room_error_c: f64,
|
||||
pub room_age_s: f64,
|
||||
/// this period's duty (0 to 1) and the share of the last hour the cards heated
|
||||
pub duty: f64,
|
||||
pub duty_hour: f64,
|
||||
/// watts of heat now (the mining cards' draw; 0 while resting or with no draw reading), the cards' draw when
|
||||
/// they heat (the last reading), and the period's average (duty times that)
|
||||
pub heat_w: f64,
|
||||
pub full_w: f64,
|
||||
pub heat_avg_w: f64,
|
||||
/// seconds until the slice changes
|
||||
pub until_s: f64,
|
||||
/// the one line under the switch
|
||||
pub note: String,
|
||||
pub period_s: f64,
|
||||
/// the idle offset in use and whether a typed reading taught it
|
||||
pub offset_c: f64,
|
||||
pub offset_learned: bool,
|
||||
}
|
||||
|
||||
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
|
||||
|
|
@ -413,14 +549,22 @@ pub struct State {
|
|||
pub clock: ClockState,
|
||||
pub address: AddressState,
|
||||
pub settings: SettingsState,
|
||||
pub heat: HeatState,
|
||||
pub dev_fee: DevFeeState,
|
||||
pub update: UpdateState,
|
||||
/// driver-check: the one install in flight (or the last), and the table's stamp
|
||||
pub drivers: crate::drivers::DriverState,
|
||||
pub ui: UiState,
|
||||
pub jobs: JobsState,
|
||||
pub events: Vec<Event>,
|
||||
pub uptime_s: u64,
|
||||
/// first-block-21: when this engine run began (unix s, the wall clock; uptime_s is monotonic and stops across a sleep)
|
||||
pub started_at: f64,
|
||||
pub now: f64,
|
||||
pub quitting: bool,
|
||||
pub live_page: String,
|
||||
/// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now`
|
||||
pub ladder: crate::ladder::LadderState,
|
||||
}
|
||||
|
||||
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.
|
||||
|
|
|
|||
|
|
@ -439,6 +439,9 @@ pub fn unsupported_reason(vendor: &str, power_default_w: f64, device: &str) -> O
|
|||
// Ember Tune (src/ember.rs, 5 October 2026): AMD is tuned through igneum-gpu-telemetry, Apple measures only;
|
||||
// the tune itself says which at its start (the card row's note)
|
||||
"apple" | "amd" => None,
|
||||
// the first Intel card, the B580 on PC 1 (7 October 2026, docs/plans/intel-arc.md section 2.3): OpenCL
|
||||
// exposes neither a cap nor a reading; Intel's path is IGCL (ControlLib.dll), the telemetry helper's next piece
|
||||
"intel" => Some("not available: Intel Arc exposes no power cap or reading through OpenCL (the driver's IGCL counters come next)"),
|
||||
_ => Some("not available: no power reading or cap for this card"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
533
app/igneum-app/src/uiota.rs
Normal file
533
app/igneum-app/src/uiota.rs
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
//! Interface over the air (docs/plans/ui-ota.md, 7 October 2026): the dashboard (app/igneum-app/ui, embedded in the
|
||||
//! engine binary) can be replaced by a signed bundle from the manifest's `ui` channel without a new app version.
|
||||
//!
|
||||
//! The flow, driven from the updater's hourly manifest (src/ota.rs hands the parsed `ui` entry over):
|
||||
//! decide: the entry is ignored when its min_engine is newer than this engine, when its version is the active
|
||||
//! one or the embedded one, when it was marked bad before, or when the miner chose the built-in interface
|
||||
//! -> download (curl to <app data>/ui/<version>.tar.gz, size and sha256 against the manifest, then the entry's own
|
||||
//! Ed25519 signature with the release key compiled into src/manifest.rs; the manifest's signature covered it too)
|
||||
//! -> unpack with the system tar into <app data>/ui/<version>.new, index.html, app.js and app.css must be there,
|
||||
//! rename to <app data>/ui/<version>
|
||||
//! -> swap: <app data>/ui/current.json names the version (written to .tmp and renamed: atomic); the server reads
|
||||
//! the pointer through Shared and serves the bundle's files in place of the embedded ones at the next page load;
|
||||
//! the open page sees state.ui.active_version change and reloads itself when idle
|
||||
//! -> confirm: the first page load from a new bundle starts a 10 s wait for the page's health ping
|
||||
//! (POST /api/ui/health after its first paint; a JS error on first paint posts the error instead); no ping, an
|
||||
//! error, or a bundle that fails to unpack rolls back to the embedded interface and marks the version bad
|
||||
//! (<app data>/ui/bad.json): it is never applied again
|
||||
//! The kill switch is the manifest without a `ui` object: the engine falls back to the embedded interface on the
|
||||
//! next check. Same origin, no remote script: the bundle is served from 127.0.0.1 by this engine like the embedded
|
||||
//! files, and the signature is the only trust.
|
||||
|
||||
use crate::engine::{Cmd, Shared};
|
||||
use crate::manifest::{self, UiEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The page's health ping must arrive this long after the first load of a new bundle.
|
||||
pub const HEALTH_WAIT_S: u64 = 10;
|
||||
/// The version of the interface compiled into this engine (app/igneum-app/ui/VERSION).
|
||||
pub const EMBEDDED_VERSION: &str = include_str!("../ui/VERSION");
|
||||
/// The files a bundle may serve: fixed names, nothing else is read from the bundle folder.
|
||||
pub const SERVED: [&str; 15] = ["index.html", "app.css", "app.js", "mark.svg", "live-dag.js", "proof-core.js", "VERSION", "fonts/IBMPlexMono-400.woff2", "fonts/IBMPlexMono-500.woff2", "fonts/IBMPlexSans-400.woff2", "fonts/IBMPlexSans-500.woff2", "fonts/IBMPlexSans-600.woff2", "fonts/Unbounded-500.woff2", "fonts/Unbounded-700.woff2", "fonts/Unbounded-900.woff2"];
|
||||
/// What a bundle must carry to be swapped in.
|
||||
const REQUIRED: [&str; 3] = ["index.html", "app.js", "app.css"];
|
||||
|
||||
pub fn embedded_version() -> &'static str {
|
||||
EMBEDDED_VERSION.trim()
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
/// the download, the checks and the unpack finished: the bundle folder, or why not (with the version)
|
||||
Installed(String, Result<PathBuf, String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Apply,
|
||||
Skip(String),
|
||||
}
|
||||
|
||||
/// Whether a published entry is for this engine now (pure; the tests cover every branch).
|
||||
pub fn decide(e: &UiEntry, engine: &str, embedded: &str, active: Option<&str>, bad: &[String], builtin: bool) -> Decision {
|
||||
if builtin {
|
||||
return Decision::Skip("the built-in interface is chosen in Settings".into());
|
||||
}
|
||||
if manifest::newer(&e.min_engine, engine) {
|
||||
return Decision::Skip(format!("interface {} needs engine {} or newer (this is {engine})", e.version, e.min_engine));
|
||||
}
|
||||
if bad.iter().any(|b| b == &e.version) {
|
||||
return Decision::Skip(format!("interface {} failed here before and is not tried again", e.version));
|
||||
}
|
||||
if active == Some(e.version.as_str()) {
|
||||
return Decision::Skip(format!("interface {} is active", e.version));
|
||||
}
|
||||
if e.version == embedded {
|
||||
return Decision::Skip(format!("interface {} is the built-in one", e.version));
|
||||
}
|
||||
Decision::Apply
|
||||
}
|
||||
|
||||
/// The pointer file: which bundle the server serves ("embedded" or a version) and when it was swapped in.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Current {
|
||||
pub version: String,
|
||||
pub installed_at: u64,
|
||||
/// the page confirmed this bundle with a health ping
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
fn read_current(dir: &Path) -> Current {
|
||||
let v: serde_json::Value = std::fs::read_to_string(dir.join("current.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(serde_json::Value::Null);
|
||||
Current { version: v.get("version").and_then(|x| x.as_str()).unwrap_or("embedded").to_string(), installed_at: v.get("installed_at").and_then(|x| x.as_u64()).unwrap_or(0), confirmed: v.get("confirmed").and_then(|x| x.as_bool()).unwrap_or(false) }
|
||||
}
|
||||
|
||||
/// Writes the pointer atomically (the .tmp then the rename).
|
||||
pub fn write_current(dir: &Path, c: &Current) -> Result<(), String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let tmp = dir.join("current.json.tmp");
|
||||
std::fs::write(&tmp, serde_json::json!({ "version": c.version, "installed_at": c.installed_at, "confirmed": c.confirmed }).to_string()).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, dir.join("current.json")).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn read_bad(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(dir.join("bad.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_bad(dir: &Path, bad: &[String]) {
|
||||
let _ = std::fs::write(dir.join("bad.json"), serde_json::to_string(bad).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// The bundle folder for a version, if it holds what the server needs.
|
||||
pub fn bundle_dir(dir: &Path, version: &str) -> Option<PathBuf> {
|
||||
if version == "embedded" || version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = dir.join(version);
|
||||
if REQUIRED.iter().all(|f| d.join(f).is_file()) { Some(d) } else { None }
|
||||
}
|
||||
|
||||
/// Size, sha256 and the entry's own signature, then the unpack into <dir>/<version>: the folder on success.
|
||||
/// `pub_hex` is the release key (manifest::OTA_PUBLIC_KEY_HEX in the engine; the tests pass their own).
|
||||
pub fn install_bundle(e: &UiEntry, file: &Path, dir: &Path, pub_hex: &str) -> Result<PathBuf, String> {
|
||||
let size = std::fs::metadata(file).map(|m| m.len()).map_err(|er| format!("{}: {er}", file.display()))?;
|
||||
if size != e.size {
|
||||
return Err(format!("interface {}: the download is {size} bytes, the manifest says {}", e.version, e.size));
|
||||
}
|
||||
let sum = manifest::sha256_file(file).map_err(|er| er.to_string())?;
|
||||
if sum != e.sha256 {
|
||||
return Err(format!("interface {}: sha256 mismatch", e.version));
|
||||
}
|
||||
manifest::verify_ui_entry(e, pub_hex).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let fresh = dir.join(format!("{}.new", e.version));
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
std::fs::create_dir_all(&fresh).map_err(|er| er.to_string())?;
|
||||
let mut c = std::process::Command::new(crate::platform::tool("tar"));
|
||||
c.args(["-xzf", &file.display().to_string(), "-C", &fresh.display().to_string()]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(60)).ok_or("tar is not available")?;
|
||||
// a bundle packed with a top-level folder: take it
|
||||
let root = if REQUIRED.iter().all(|f| fresh.join(f).is_file()) {
|
||||
fresh.clone()
|
||||
} else {
|
||||
let inner = std::fs::read_dir(&fresh).ok().into_iter().flatten().filter_map(|d| d.ok()).map(|d| d.path()).find(|p| p.is_dir() && REQUIRED.iter().all(|f| p.join(f).is_file()));
|
||||
match inner {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
return Err(format!("interface {}: the bundle has no index.html, app.js and app.css ({})", e.version, out.lines().last().unwrap_or("tar said nothing")));
|
||||
}
|
||||
}
|
||||
};
|
||||
let dest = dir.join(&e.version);
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
std::fs::rename(&root, &dest).map_err(|er| format!("interface {}: {er}", e.version))?;
|
||||
let _ = std::fs::remove_dir_all(&fresh);
|
||||
// the bundle's own VERSION must agree with the manifest (a renamed bundle is refused)
|
||||
let v = std::fs::read_to_string(dest.join("VERSION")).unwrap_or_default();
|
||||
if v.trim() != e.version {
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
return Err(format!("interface {}: the bundle's VERSION file says '{}'", e.version, v.trim()));
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// The engine's side: what is active, what is pending, the health wait, the rollback.
|
||||
pub struct UiOta {
|
||||
dir: PathBuf,
|
||||
current: Current,
|
||||
bad: Vec<String>,
|
||||
builtin: bool,
|
||||
busy: bool,
|
||||
/// the manifest entry seen last (for the Settings line and the retry after an error)
|
||||
entry: Option<UiEntry>,
|
||||
/// a bundle served to a page and not yet confirmed: (version, when the page loaded)
|
||||
waiting: Option<(String, Instant)>,
|
||||
error: String,
|
||||
/// the whole download thread's last reason, kept for the state
|
||||
last_skip: String,
|
||||
health_wait: Duration,
|
||||
}
|
||||
|
||||
impl UiOta {
|
||||
pub fn new(shared: &Arc<Shared>) -> UiOta {
|
||||
let dir = shared.runtime.app_dir.join("ui");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let builtin = shared.settings.lock().unwrap().ui_builtin;
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: read_bad(&dir), builtin, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(HEALTH_WAIT_S) };
|
||||
// a pointer to a folder that is not there (a cleaned app data folder) falls back without a word
|
||||
if bundle_dir(&dir, &u.current.version).is_none() && u.current.version != "embedded" {
|
||||
shared.log(&format!("ui: the pointer names interface {} but its folder is gone; the built-in interface serves", u.current.version));
|
||||
u.current = Current { version: "embedded".into(), installed_at: 0, confirmed: true };
|
||||
let _ = write_current(&dir, &u.current);
|
||||
}
|
||||
u.apply_pointer(shared);
|
||||
if u.current.version != "embedded" {
|
||||
shared.log(&format!("ui bundle {} active (installed {}){}", u.current.version, u.current.installed_at, if u.builtin { ", but the built-in interface is chosen" } else { "" }));
|
||||
}
|
||||
u.publish(shared);
|
||||
u
|
||||
}
|
||||
|
||||
/// What the server serves: the bundle folder, or None for the embedded files.
|
||||
fn apply_pointer(&self, shared: &Arc<Shared>) {
|
||||
let d = if self.builtin { None } else { bundle_dir(&self.dir, &self.current.version) };
|
||||
shared.set_ui_dir(d);
|
||||
}
|
||||
|
||||
pub fn active_version(&self) -> &str {
|
||||
&self.current.version
|
||||
}
|
||||
|
||||
/// Called with every verified manifest: the `ui` entry or None (the kill switch).
|
||||
pub fn consider(&mut self, shared: &Arc<Shared>, entry: Option<&UiEntry>, engine: &str) {
|
||||
let Some(e) = entry else {
|
||||
self.entry = None;
|
||||
if self.current.version != "embedded" {
|
||||
shared.event("info", &format!("the interface channel was withdrawn; the built-in interface {} serves again", embedded_version()));
|
||||
shared.log(&format!("ui: no ui object in the manifest; interface {} retired", self.current.version));
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
self.publish(shared);
|
||||
return;
|
||||
};
|
||||
self.entry = Some(e.clone());
|
||||
if self.busy {
|
||||
return;
|
||||
}
|
||||
match decide(e, engine, embedded_version(), Some(&self.current.version), &self.bad, self.builtin) {
|
||||
Decision::Skip(why) => {
|
||||
if why != self.last_skip {
|
||||
shared.log(&format!("ui: {why}"));
|
||||
self.last_skip = why;
|
||||
}
|
||||
}
|
||||
Decision::Apply => {
|
||||
self.last_skip = String::new();
|
||||
self.busy = true;
|
||||
self.error = String::new();
|
||||
shared.event("info", &format!("interface {} is published: downloading ({} KB)", e.version, e.size / 1000));
|
||||
let e2 = e.clone();
|
||||
let dir = self.dir.clone();
|
||||
let shared2 = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let r = download_and_install(&e2, &dir);
|
||||
shared2.send(Cmd::UiOta(Event::Installed(e2.version.clone(), r)));
|
||||
});
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
pub fn event(&mut self, shared: &Arc<Shared>, ev: Event) {
|
||||
self.busy = false;
|
||||
match ev {
|
||||
Event::Installed(version, Ok(_dir)) => {
|
||||
shared.event("ok", &format!("interface {version} installed; the window takes it at its next load"));
|
||||
self.set_current(shared, Current { version: version.clone(), installed_at: crate::platform::unix_now(), confirmed: false });
|
||||
shared.log(&format!("ui bundle {version} active (installed {}), awaiting the page's health ping", self.current.installed_at));
|
||||
}
|
||||
Event::Installed(version, Err(e)) => {
|
||||
self.mark_bad(shared, &version, &e);
|
||||
}
|
||||
}
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
fn set_current(&mut self, shared: &Arc<Shared>, c: Current) {
|
||||
self.current = c;
|
||||
if let Err(e) = write_current(&self.dir, &self.current) {
|
||||
shared.log(&format!("ui: could not write the pointer: {e}"));
|
||||
}
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
}
|
||||
|
||||
fn mark_bad(&mut self, shared: &Arc<Shared>, version: &str, why: &str) {
|
||||
if !self.bad.iter().any(|b| b == version) {
|
||||
self.bad.push(version.to_string());
|
||||
write_bad(&self.dir, &self.bad);
|
||||
}
|
||||
self.error = why.to_string();
|
||||
shared.event("error", &format!("interface {version} was refused: {why}. The built-in interface serves"));
|
||||
shared.log(&format!("ui: {version} marked bad: {why}"));
|
||||
if self.current.version == version {
|
||||
self.set_current(shared, Current { version: "embedded".into(), installed_at: crate::platform::unix_now(), confirmed: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// The server served index.html from a bundle: an unconfirmed one starts the health wait.
|
||||
pub fn page_loaded(&mut self, version: &str, now: Instant) {
|
||||
if version == self.current.version && !self.current.confirmed && self.waiting.is_none() {
|
||||
self.waiting = Some((version.to_string(), now));
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's ping after its first paint, or the error it caught before it.
|
||||
pub fn health(&mut self, shared: &Arc<Shared>, error: Option<&str>) {
|
||||
let Some((version, _)) = self.waiting.clone() else { return };
|
||||
match error {
|
||||
None => {
|
||||
self.waiting = None;
|
||||
self.current.confirmed = true;
|
||||
let _ = write_current(&self.dir, &self.current);
|
||||
shared.log(&format!("ui bundle {version} confirmed by the page"));
|
||||
self.publish(shared);
|
||||
}
|
||||
Some(e) => {
|
||||
self.mark_bad(shared, &version, &format!("a script error on first paint: {}", e.chars().take(200).collect::<String>()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wait ran out: the page never confirmed the bundle.
|
||||
pub fn tick(&mut self, shared: &Arc<Shared>, now: Instant) {
|
||||
if let Some((version, since)) = self.waiting.clone() {
|
||||
if now.duration_since(since) >= self.health_wait {
|
||||
self.mark_bad(shared, &version, &format!("the page did not answer within {} s of loading it", self.health_wait.as_secs()));
|
||||
self.publish(shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_builtin(&mut self, shared: &Arc<Shared>, on: bool) {
|
||||
self.builtin = on;
|
||||
{
|
||||
let mut s = shared.settings.lock().unwrap();
|
||||
s.ui_builtin = on;
|
||||
s.save(&shared.settings_path);
|
||||
}
|
||||
shared.state.lock().unwrap().settings.ui_builtin = on;
|
||||
self.waiting = None;
|
||||
self.apply_pointer(shared);
|
||||
shared.event("info", if on { "the built-in interface serves from the next page load" } else { "the over-the-air interface serves again when one is active" });
|
||||
self.publish(shared);
|
||||
}
|
||||
|
||||
/// Settings > Interface: what serves, from where, since when.
|
||||
pub fn publish(&self, shared: &Arc<Shared>) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
let u = &mut st.ui;
|
||||
let ota_active = !self.builtin && bundle_dir(&self.dir, &self.current.version).is_some();
|
||||
u.embedded_version = embedded_version().into();
|
||||
u.active_version = if ota_active { self.current.version.clone() } else { embedded_version().into() };
|
||||
u.source = if ota_active { "ota".into() } else { "embedded".into() };
|
||||
u.installed_at = if ota_active { self.current.installed_at as f64 } else { 0.0 };
|
||||
u.confirmed = !ota_active || self.current.confirmed;
|
||||
u.published_version = self.entry.as_ref().map(|e| e.version.clone()).unwrap_or_default();
|
||||
u.busy = self.busy;
|
||||
u.error = self.error.clone();
|
||||
u.bad = self.bad.clone();
|
||||
u.builtin = self.builtin;
|
||||
u.note = self.last_skip.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// The download thread: curl with resume into <dir>/<version>.tar.gz, then install_bundle.
|
||||
fn download_and_install(e: &UiEntry, dir: &Path) -> Result<PathBuf, String> {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let file = dir.join(format!("{}.tar.gz", e.version));
|
||||
let part = dir.join(format!("{}.tar.gz.part", e.version));
|
||||
let mut c = std::process::Command::new(crate::platform::tool("curl"));
|
||||
c.args(["-fsSL", "--max-time", "300", "-C", "-", "-o", &part.display().to_string(), &e.url]);
|
||||
let out = crate::detect::run_timeout(&mut c, None, Duration::from_secs(320)).ok_or("curl is not available")?;
|
||||
let t = out.trim();
|
||||
if !t.is_empty() && !part.is_file() {
|
||||
return Err(format!("interface {}: {}", e.version, t.lines().last().unwrap_or("curl failed")));
|
||||
}
|
||||
std::fs::rename(&part, &file).map_err(|er| er.to_string())?;
|
||||
let r = install_bundle(e, &file, dir, manifest::OTA_PUBLIC_KEY_HEX);
|
||||
if r.is_err() {
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The content type a served bundle file gets (the same list the embedded files use).
|
||||
pub fn content_type(rel: &str) -> &'static str {
|
||||
if rel.ends_with(".html") { "text/html; charset=utf-8" } else if rel.ends_with(".css") { "text/css; charset=utf-8" } else if rel.ends_with(".js") { "application/javascript; charset=utf-8" } else if rel.ends_with(".svg") { "image/svg+xml" } else if rel.ends_with(".woff2") { "font/woff2" } else { "text/plain; charset=utf-8" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
fn entry(version: &str, min_engine: &str) -> UiEntry {
|
||||
UiEntry { version: version.into(), sha256: "ab".repeat(32), size: 1, url: "https://dl.igneum.network/dl/x/ui/igneum-ui-1.0.1.tar.gz".into(), min_engine: min_engine.into(), signature: "cd".repeat(64) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decision_covers_every_reason_to_skip() {
|
||||
let e = entry("1.0.1", "0.3.19");
|
||||
assert_eq!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], false), Decision::Apply);
|
||||
assert_eq!(decide(&e, "0.3.20", "1.0.0", Some("1.0.0"), &[], false), Decision::Apply);
|
||||
assert!(matches!(decide(&e, "0.3.18", "1.0.0", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("needs engine 0.3.19")), "a too-new min_engine is ignored");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("1.0.1"), &[], false), Decision::Skip(w) if w.contains("is active")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.1", Some("embedded"), &[], false), Decision::Skip(w) if w.contains("built-in one")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &["1.0.1".to_string()], false), Decision::Skip(w) if w.contains("failed here before")));
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &[], true), Decision::Skip(w) if w.contains("chosen in Settings")));
|
||||
}
|
||||
|
||||
/// A bundle folder, packed with the system tar, hashed and signed with a throwaway key.
|
||||
fn make_bundle(root: &Path, version: &str, with_index: bool, sk: &SigningKey) -> (UiEntry, PathBuf) {
|
||||
let src = root.join("src");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
std::fs::create_dir_all(src.join("fonts")).unwrap();
|
||||
if with_index {
|
||||
std::fs::write(src.join("index.html"), "<!doctype html><title>x</title>").unwrap();
|
||||
}
|
||||
std::fs::write(src.join("app.js"), "var x = 1;").unwrap();
|
||||
std::fs::write(src.join("app.css"), "body{}").unwrap();
|
||||
std::fs::write(src.join("VERSION"), format!("{version}\n")).unwrap();
|
||||
std::fs::write(src.join("fonts/IBMPlexMono-400.woff2"), b"wOF2").unwrap();
|
||||
let tar = root.join(format!("igneum-ui-{version}.tar.gz"));
|
||||
let mut c = std::process::Command::new("tar");
|
||||
c.args(["-czf", &tar.display().to_string(), "-C", &src.display().to_string(), "."]);
|
||||
let ok = crate::platform::quiet(&mut c).status().unwrap().success();
|
||||
assert!(ok, "tar packs the fixture");
|
||||
let sha = manifest::sha256_file(&tar).unwrap();
|
||||
let size = std::fs::metadata(&tar).unwrap().len();
|
||||
let sig = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes(version, &sha, "0.3.19")).to_bytes());
|
||||
(UiEntry { version: version.into(), sha256: sha, size, url: "https://dl.igneum.network/dl/x/ui/x.tar.gz".into(), min_engine: "0.3.19".into(), signature: sig }, tar)
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("igneum-uiota-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_bundle_installs_and_the_pointer_swaps_atomically() {
|
||||
let root = tmp("good");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.1", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let dest = install_bundle(&e, &tar, &dir, &pk).expect("installs");
|
||||
assert_eq!(dest, dir.join("1.0.1"));
|
||||
assert!(bundle_dir(&dir, "1.0.1").is_some());
|
||||
assert!(!dir.join("1.0.1.new").exists(), "the staging folder is gone");
|
||||
write_current(&dir, &Current { version: "1.0.1".into(), installed_at: 5, confirmed: false }).unwrap();
|
||||
assert_eq!(read_current(&dir).version, "1.0.1");
|
||||
assert!(!dir.join("current.json.tmp").exists());
|
||||
assert_eq!(content_type("app.js"), "application/javascript; charset=utf-8");
|
||||
assert_eq!(bundle_dir(&dir, "embedded"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_signature_is_refused_before_anything_is_unpacked() {
|
||||
let root = tmp("badsig");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let other = manifest::hex_encode(SigningKey::from_bytes(&[8u8; 32]).verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.2", true, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &other).unwrap_err();
|
||||
assert!(err.contains("signature does not verify"), "{err}");
|
||||
assert!(!dir.join("1.0.2").exists());
|
||||
// a tampered hash is caught first
|
||||
let mut t = e.clone();
|
||||
t.sha256 = "00".repeat(32);
|
||||
let err = install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err();
|
||||
assert!(err.contains("sha256 mismatch"), "{err}");
|
||||
// a wrong size too
|
||||
let mut t = e.clone();
|
||||
t.size += 1;
|
||||
assert!(install_bundle(&t, &tar, &dir, &manifest::hex_encode(sk.verifying_key().as_bytes())).unwrap_err().contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_bundle_without_index_html_is_refused_and_leaves_nothing_behind() {
|
||||
let root = tmp("broken");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (e, tar) = make_bundle(&root, "1.0.3", false, &sk);
|
||||
let dir = root.join("ui");
|
||||
let err = install_bundle(&e, &tar, &dir, &pk).unwrap_err();
|
||||
assert!(err.contains("no index.html"), "{err}");
|
||||
assert!(!dir.join("1.0.3").exists() && !dir.join("1.0.3.new").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_bundle_is_refused_by_its_version_file() {
|
||||
let root = tmp("renamed");
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
let (mut e, tar) = make_bundle(&root, "1.0.4", true, &sk);
|
||||
e.version = "1.0.5".into();
|
||||
e.signature = manifest::hex_encode(&sk.sign(&manifest::ui_sign_bytes("1.0.5", &e.sha256, "0.3.19")).to_bytes());
|
||||
let err = install_bundle(&e, &tar, &root.join("ui"), &pk).unwrap_err();
|
||||
assert!(err.contains("VERSION file says '1.0.4'"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_health_wait_rolls_back_to_the_embedded_interface_and_marks_the_version_bad() {
|
||||
// the state machine without threads: a UiOta over a temp dir, driven by hand
|
||||
let root = tmp("health");
|
||||
let dir = root.join("ui");
|
||||
std::fs::create_dir_all(dir.join("1.0.6")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.6").join(f), "x").unwrap();
|
||||
}
|
||||
write_current(&dir, &Current { version: "1.0.6".into(), installed_at: 1, confirmed: false }).unwrap();
|
||||
let mut u = UiOta { dir: dir.clone(), current: read_current(&dir), bad: vec![], builtin: false, busy: false, entry: None, waiting: None, error: String::new(), last_skip: String::new(), health_wait: Duration::from_secs(10) };
|
||||
let t0 = Instant::now();
|
||||
u.page_loaded("1.0.6", t0);
|
||||
assert!(u.waiting.is_some());
|
||||
// a ping in time confirms
|
||||
let shared = crate::engine::Shared::for_tests(root.join("data"));
|
||||
u.health(&shared, None);
|
||||
assert!(u.current.confirmed && u.waiting.is_none());
|
||||
assert_eq!(read_current(&dir).confirmed, true);
|
||||
// a second bundle that never answers
|
||||
std::fs::create_dir_all(dir.join("1.0.7")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.7").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.7".into(), installed_at: 2, confirmed: false });
|
||||
u.page_loaded("1.0.7", t0);
|
||||
u.tick(&shared, t0 + Duration::from_secs(9));
|
||||
assert_eq!(u.current.version, "1.0.7", "still waiting inside the window");
|
||||
u.tick(&shared, t0 + Duration::from_secs(10));
|
||||
assert_eq!(u.current.version, "embedded", "rolled back");
|
||||
assert_eq!(read_current(&dir).version, "embedded");
|
||||
assert_eq!(read_bad(&dir), vec!["1.0.7".to_string()]);
|
||||
assert!(shared.ui_dir().is_none(), "the server serves the embedded files again");
|
||||
// a script error on first paint rolls back the same way
|
||||
std::fs::create_dir_all(dir.join("1.0.8")).unwrap();
|
||||
for f in REQUIRED {
|
||||
std::fs::write(dir.join("1.0.8").join(f), "x").unwrap();
|
||||
}
|
||||
u.set_current(&shared, Current { version: "1.0.8".into(), installed_at: 3, confirmed: false });
|
||||
u.page_loaded("1.0.8", t0);
|
||||
u.health(&shared, Some("TypeError: x is not a function"));
|
||||
assert_eq!(u.current.version, "embedded");
|
||||
assert!(u.bad.contains(&"1.0.8".to_string()));
|
||||
// and the decision never applies it again
|
||||
let e = entry("1.0.8", "0.3.19");
|
||||
assert!(matches!(decide(&e, "0.3.19", "1.0.0", Some("embedded"), &u.bad, false), Decision::Skip(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -40,22 +40,37 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
|||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||
/// unreachable node reads false: the block sample waits rather than asks.
|
||||
pub fn exec_has_record(evm_port: u16) -> bool {
|
||||
crate::execrpc::has_record(evm_port)
|
||||
}
|
||||
|
||||
/// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string.
|
||||
pub fn exec_status_has_record(reply: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(reply).ok().map(|v| crate::execrpc::status_has_record(v.get("result").unwrap_or(&serde_json::Value::Null))).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the
|
||||
/// consensus block times). The first clock source: local time against what the peers produced.
|
||||
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
|
||||
let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}";
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]),
|
||||
None,
|
||||
Duration::from_secs(7),
|
||||
)?;
|
||||
let v: serde_json::Value = serde_json::from_str(&out).ok()?;
|
||||
let ts = v.get("result")?.get("timestamp")?.as_str()?;
|
||||
// 0.3.18/0.3.19 (ledger N7): through the one gate, which asks nothing of a follower without a record
|
||||
let block = crate::execrpc::call(evm_port, "eth_getBlockByNumber", serde_json::json!(["latest", false]), Duration::from_secs(5)).ok()?;
|
||||
let ts = block.get("timestamp")?.as_str()?;
|
||||
u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Ledger N7: the clock sample must not ask eth_getBlockByNumber of a follower with no record
|
||||
#[test]
|
||||
fn exec_status_gate() {
|
||||
assert!(super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec31b9227c3855a1b25ba50ee9f6815bf00b0befcc8836b430baf04343"}}"#));
|
||||
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x0","executedTipHash":null}}"#));
|
||||
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#));
|
||||
assert!(!super::exec_status_has_record(""));
|
||||
assert!(!super::exec_status_has_record("not json"));
|
||||
}
|
||||
#[test]
|
||||
fn http_date() {
|
||||
assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0));
|
||||
|
|
@ -79,6 +94,15 @@ pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str
|
|||
let tail: String = String::from_utf8_lossy(&data).lines().filter(|l| !crate::platform::carries_token(l)).map(|l| crate::platform::redact(l)).collect::<Vec<_>>().join("\n");
|
||||
// the first line of every upload names the app, the machine and the node (the console parses it)
|
||||
let text = format!("{header}\n{tail}");
|
||||
upload_text(url, key, label, machine, run_id, &text)
|
||||
}
|
||||
|
||||
/// One upload of ready text (a fault report, a job's lines): the same intake, the same token guard.
|
||||
pub fn upload_text(url: &str, key: &str, label: &str, machine: &str, run_id: &str, text: &str) -> bool {
|
||||
if url.is_empty() || key.is_empty() || text.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let text: String = text.lines().filter(|l| !crate::platform::carries_token(l)).collect::<Vec<_>>().join("\n");
|
||||
let body = serde_json::json!({ "label": label, "machine": machine, "run_id": run_id, "lines": text });
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-upload-{}-{}.json", std::process::id(), label));
|
||||
if std::fs::write(&tmp, body.to_string()).is_err() {
|
||||
|
|
|
|||
|
|
@ -2,21 +2,79 @@
|
|||
//! rules can be unit-tested with recorded miner lines.
|
||||
//!
|
||||
//! Per card (`CardWatch`): a miner that prints no status line for 90 s, or reports a hash rate of 0 for 60 s while
|
||||
//! the node is synced, is restarted once; when that recurs before five minutes of healthy status, the card is marked
|
||||
//! faulted with the reason, its miner is not restarted again, and the other cards keep mining. The miner's own
|
||||
//! worker restarts (`WORKER FAULT`, `worker exited`) suspend both rules until the worker is ready again, so the app
|
||||
//! never restarts a miner that is already restarting its worker (no double restarts); if the worker is not back
|
||||
//! within 180 s the app steps in. Exit code 43 (the miner gave up on its worker after three guard trips) counts
|
||||
//! like a watchdog restart: once, then faulted.
|
||||
//! the node is ready, is restarted; when that recurs before five minutes of healthy status the next restart waits
|
||||
//! longer (10 s, 30 s, 2 min, 5 min, then every 5 min, for ever: the project lead, 7 October 2026, "it needs to be truly plug,
|
||||
//! tune, play"; no fault is permanent and the old one-restart-then-faulted state no longer exists). The reason stays on
|
||||
//! the card in plain words and the hash resumes on its own. A miner is judged only while the node is READY: synced
|
||||
//! and with an executed tip (`igneum_getExecStatus`); with no template it cannot print status, so every silence clock
|
||||
//! holds while the node catches up (PC 1, 7 October 2026: three workers faulted "no status line" during the node's
|
||||
//! catch-up and stayed faulted until a cards-API bounce). The miner's own worker restarts (`WORKER FAULT`,
|
||||
//! `worker exited`) suspend both rules until the worker is ready again, so the app never restarts a miner that is
|
||||
//! already restarting its worker (no double restarts); if the worker is not back within 180 s the app steps in.
|
||||
//! Exit code 43 (the miner gave up on its worker after three guard trips) is a watchdog restart on the same ladder.
|
||||
//!
|
||||
//! Per node (`NodeWatch`): a node of ours that answers no `watch` reading for 120 s is restarted by the app, with a
|
||||
//! growing delay when it repeats inside ten minutes.
|
||||
//! Per node (`NodeWatch`): a node of ours that gives no sign of life for 120 s is restarted by the app, with a
|
||||
//! growing delay when it repeats inside ten minutes. Its catch-up never counts: until the node has been read as
|
||||
//! synced once since its start, silence is not held against it (a node that never answers at all is restarted after
|
||||
//! 30 minutes), and any RPC answer (the watch reading, the exec status probe, an accepted block) is a sign of life.
|
||||
//!
|
||||
//! Review round 4, X21 (4 October 2026): the app now reads `mismatched=` and `faults=` from STATUS lines and the
|
||||
//! `WORKER FAULT` lines, and shows them on the card.
|
||||
|
||||
/// No status line from a running miner for this long: restart it.
|
||||
pub const NO_STATUS_S: f64 = 90.0;
|
||||
/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then
|
||||
/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s).
|
||||
pub const START_S: f64 = 60.0;
|
||||
/// The start of a restart reason the node's readiness, not the card, explains: the ladder resets when the node syncs.
|
||||
const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"];
|
||||
/// Seconds before the n-th restart of a card for a repeated fault (the last value repeats for ever).
|
||||
pub const RETRY_LADDER_S: [u64; 4] = [10, 30, 120, 300];
|
||||
/// A node that has never answered since its start is given this long before the watchdog restarts it.
|
||||
pub const NODE_STARTUP_CAP_S: f64 = 1800.0;
|
||||
/// A worker that has not reported its program loaded (`ready`) this long after its start is restarted on the ladder.
|
||||
/// The status clocks start at `ready`, never before (MF-4, 7 October 2026: two cards sat in "loading the program"
|
||||
/// behind a third card's export storm and were faulted at 90 s).
|
||||
pub const LOAD_S: f64 = 300.0;
|
||||
/// A miner that exits (any code but 0, 42, 43, 44) within this many seconds of its start is in a crash loop: its
|
||||
/// restarts follow the ladder instead of the 5 to 60 s jitter.
|
||||
pub const EARLY_EXIT_S: f64 = 120.0;
|
||||
/// A card whose worker failed its self-test is held this long before the next try (or until its driver changes).
|
||||
pub const SELF_TEST_HOLD_S: u64 = 1800;
|
||||
|
||||
/// MF-14 (PC 2, 7 October 2026 18:34 BST: two rows sat on "exporting this hour's program" for 18 minutes while a third
|
||||
/// card mined): a worker never waits on the program export longer than one retry interval without the reason shown;
|
||||
/// past two intervals the wait is over, the row names what blocked it and the worker retries on its own interval.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExportWait {
|
||||
/// inside one interval: the row keeps "exporting this hour's program"
|
||||
Waiting,
|
||||
/// past one interval: the row says how long and what blocks the export
|
||||
Say(String),
|
||||
/// past two intervals: the wait ends; the reason, for the restart on the ladder
|
||||
GiveUp(String),
|
||||
}
|
||||
|
||||
/// `waited_s` since the export was asked, `interval_s` the card's current retry interval (the ladder rung, at least
|
||||
/// 30 s), `blocker` what holds the export now (another card's export, the pack directory lock, the node not at the
|
||||
/// epoch, the last export error), or empty when nothing is known.
|
||||
pub fn export_wait(waited_s: f64, interval_s: f64, blocker: &str) -> ExportWait {
|
||||
let interval = interval_s.max(30.0);
|
||||
let what = if blocker.trim().is_empty() { "the export has not returned".to_string() } else { blocker.trim().to_string() };
|
||||
if waited_s >= 2.0 * interval {
|
||||
ExportWait::GiveUp(format!("the program export did not return in {} s ({what}); the worker retries on its own interval", waited_s as u64))
|
||||
} else if waited_s >= interval {
|
||||
ExportWait::Say(format!("exporting this hour's program: {} s so far ({what})", waited_s as u64))
|
||||
} else {
|
||||
ExportWait::Waiting
|
||||
}
|
||||
}
|
||||
|
||||
/// The delay before restart number `attempt` (1-based) of a card: the ladder, then its last step for ever.
|
||||
pub fn retry_delay_s(attempt: u32) -> u64 {
|
||||
let i = (attempt.max(1) as usize - 1).min(RETRY_LADDER_S.len() - 1);
|
||||
RETRY_LADDER_S[i]
|
||||
}
|
||||
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
|
||||
pub const ZERO_RATE_S: f64 = 60.0;
|
||||
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
|
||||
|
|
@ -45,6 +103,12 @@ pub struct Status {
|
|||
pub faults: u64,
|
||||
pub restarts: u64,
|
||||
pub synced: bool,
|
||||
/// seconds the miner has waited for a template with none known (`template_wait=`, 0.3.20 miners; 0 before)
|
||||
pub template_wait_s: f64,
|
||||
/// the node's last template time in ms (`template_ms=`; 0 when the line has none)
|
||||
pub template_ms: f64,
|
||||
/// identities the miner fetches for now (`identities_active=`; 0 when the line has none)
|
||||
pub identities_active: u64,
|
||||
}
|
||||
|
||||
/// Parses a miner STATUS line; None for any other line.
|
||||
|
|
@ -59,6 +123,9 @@ pub fn parse_status(text: &str) -> Option<Status> {
|
|||
faults: kv_u64(text, "faults").unwrap_or(0),
|
||||
restarts: kv_u64(text, "restarts").unwrap_or(0),
|
||||
synced: kv(text, "synced") == Some("true"),
|
||||
template_wait_s: kv_f64(text, "template_wait").unwrap_or(0.0),
|
||||
template_ms: kv_f64(text, "template_ms").unwrap_or(0.0),
|
||||
identities_active: kv_u64(text, "identities_active").unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -86,15 +153,26 @@ pub enum Event<'a> {
|
|||
Stopped,
|
||||
/// The user changed the card's settings: a faulted card may try again.
|
||||
Reset,
|
||||
/// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October
|
||||
/// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back).
|
||||
NodeSynced,
|
||||
/// The worker failed its self-test (MF-4): the card is held for `SELF_TEST_HOLD_S` with the reason on its row.
|
||||
SelfTestFailed(&'a str),
|
||||
/// The card's driver or platform changed (a re-enumeration): a held card tries again now.
|
||||
DriverChanged,
|
||||
/// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1,
|
||||
/// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the
|
||||
/// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's
|
||||
/// fault while this goes on; the miner is judged again once templates flow.
|
||||
TemplateTimeout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
None,
|
||||
/// Stop the miner and start it again now, for this reason.
|
||||
Restart(String),
|
||||
/// Mark the card faulted with this reason; do not restart its miner.
|
||||
Fault(String),
|
||||
/// Stop the miner and start it again after `delay_s`, for this reason (`attempt` counts restarts since the card
|
||||
/// was last healthy for five minutes).
|
||||
Restart { reason: String, delay_s: u64, attempt: u32 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
@ -102,13 +180,27 @@ pub struct CardWatch {
|
|||
started_s: Option<f64>,
|
||||
last_status_s: Option<f64>,
|
||||
ready: bool,
|
||||
/// when the worker reported its program loaded: the status clocks start here
|
||||
ready_s: Option<f64>,
|
||||
/// held after a self-test failure until the driver changes or the hold passes
|
||||
driver_hold: bool,
|
||||
zero_since: Option<f64>,
|
||||
healthy_since: Option<f64>,
|
||||
worker_restart_since: Option<f64>,
|
||||
/// app-level restarts without five healthy minutes since
|
||||
/// app-level restarts without five healthy minutes since (the ladder position)
|
||||
restarts: u32,
|
||||
faulted: Option<String>,
|
||||
/// the reason of the last restart (a node-caused one resets the ladder when the node syncs)
|
||||
last_reason: String,
|
||||
last_fault: String,
|
||||
/// the miner's last line was a template timeout (the node not answering), cleared by the next status line
|
||||
templates_blocked: bool,
|
||||
}
|
||||
|
||||
impl CardWatch {
|
||||
/// True while the miner's last word was a template timeout: the node, not the card, holds the hashing.
|
||||
pub fn templates_blocked(&self) -> bool {
|
||||
self.templates_blocked
|
||||
}
|
||||
}
|
||||
|
||||
impl CardWatch {
|
||||
|
|
@ -116,10 +208,7 @@ impl CardWatch {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
pub fn faulted(&self) -> Option<&str> {
|
||||
self.faulted.as_deref()
|
||||
}
|
||||
|
||||
/// Restarts since the card was last healthy for five minutes: the ladder position.
|
||||
pub fn watchdog_restarts(&self) -> u32 {
|
||||
self.restarts
|
||||
}
|
||||
|
|
@ -128,6 +217,7 @@ impl CardWatch {
|
|||
self.started_s = Some(now_s);
|
||||
self.last_status_s = None;
|
||||
self.ready = false;
|
||||
self.ready_s = None;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
|
|
@ -140,14 +230,9 @@ impl CardWatch {
|
|||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
self.worker_restart_since = None;
|
||||
if self.restarts >= 1 {
|
||||
let r = format!("{reason} (restarted once already)");
|
||||
self.faulted = Some(r.clone());
|
||||
Action::Fault(r)
|
||||
} else {
|
||||
self.restarts += 1;
|
||||
Action::Restart(reason)
|
||||
}
|
||||
self.restarts = self.restarts.saturating_add(1);
|
||||
self.last_reason = reason.clone();
|
||||
Action::Restart { reason, delay_s: retry_delay_s(self.restarts), attempt: self.restarts }
|
||||
}
|
||||
|
||||
pub fn event(&mut self, now_s: f64, ev: Event<'_>) -> Action {
|
||||
|
|
@ -158,11 +243,22 @@ impl CardWatch {
|
|||
}
|
||||
Event::Ready => {
|
||||
self.ready = true;
|
||||
self.ready_s = Some(now_s);
|
||||
self.driver_hold = false;
|
||||
self.worker_restart_since = None;
|
||||
Action::None
|
||||
}
|
||||
Event::Status(s) => {
|
||||
self.last_status_s = Some(now_s);
|
||||
if s.template_wait_s > 0.0 && s.hash_now <= 0.0 {
|
||||
// MF-5: the miner is alive and waiting on the node for a template; a zero rate here is the
|
||||
// node's latency, never the card's fault
|
||||
self.templates_blocked = true;
|
||||
self.zero_since = None;
|
||||
self.healthy_since = None;
|
||||
return Action::None;
|
||||
}
|
||||
self.templates_blocked = false;
|
||||
if s.hash_now > 0.0 {
|
||||
self.zero_since = None;
|
||||
let since = *self.healthy_since.get_or_insert(now_s);
|
||||
|
|
@ -186,15 +282,35 @@ impl CardWatch {
|
|||
Action::None
|
||||
}
|
||||
Event::Exited(code) => {
|
||||
let running = self.started_s.is_some();
|
||||
let started = self.started_s;
|
||||
self.started_s = None;
|
||||
if code == MINER_GAVE_UP_CODE && running {
|
||||
if code == MINER_GAVE_UP_CODE && started.is_some() {
|
||||
let why = if self.last_fault.is_empty() { "its guards tripped three times in ten minutes".to_string() } else { self.last_fault.clone() };
|
||||
self.escalate(format!("the miner gave up on its worker: {why}"))
|
||||
} else {
|
||||
Action::None
|
||||
match started {
|
||||
// a crash loop: the ladder, not the 5 to 60 s jitter (MF-4)
|
||||
Some(t) if !matches!(code, 0 | 42 | 43 | PACK_OUT_OF_DATE_CODE) && now_s - t < EARLY_EXIT_S => {
|
||||
self.escalate(format!("the miner exited with code {code} {:.0} s after starting", now_s - t))
|
||||
}
|
||||
_ => Action::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::SelfTestFailed(reason) => {
|
||||
self.started_s = None;
|
||||
self.ready = false;
|
||||
self.driver_hold = true;
|
||||
self.last_reason = format!("not usable on this driver: {reason}");
|
||||
Action::Restart { reason: self.last_reason.clone(), delay_s: SELF_TEST_HOLD_S, attempt: self.restarts.max(1) }
|
||||
}
|
||||
Event::DriverChanged => {
|
||||
if self.driver_hold {
|
||||
self.driver_hold = false;
|
||||
self.restarts = 0;
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
Event::Stopped => {
|
||||
self.started_s = None;
|
||||
self.last_status_s = None;
|
||||
|
|
@ -207,14 +323,39 @@ impl CardWatch {
|
|||
*self = Self::default();
|
||||
Action::None
|
||||
}
|
||||
Event::TemplateTimeout => {
|
||||
// the miner's own heartbeat: every silence clock starts over from this line
|
||||
self.started_s = Some(now_s);
|
||||
if self.last_status_s.is_some() {
|
||||
self.last_status_s = Some(now_s);
|
||||
}
|
||||
self.zero_since = None;
|
||||
self.templates_blocked = true;
|
||||
Action::None
|
||||
}
|
||||
Event::NodeSynced => {
|
||||
// restarts the node's readiness explained do not count against the card
|
||||
if self.restarted_for_node() {
|
||||
self.restarts = 0;
|
||||
self.last_reason.clear();
|
||||
}
|
||||
Action::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called every engine tick while the miner process is alive.
|
||||
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
|
||||
if self.faulted.is_some() {
|
||||
return Action::None;
|
||||
}
|
||||
/// True while the card waits out a self-test failure (released by a driver change or the hold's end).
|
||||
pub fn driver_hold(&self) -> bool {
|
||||
self.driver_hold
|
||||
}
|
||||
|
||||
/// True when the last restart was for a reason the node's readiness explains (released by Event::NodeSynced).
|
||||
pub fn restarted_for_node(&self) -> bool {
|
||||
NODE_FAULTS.iter().any(|p| self.last_reason.starts_with(p))
|
||||
}
|
||||
|
||||
/// Called every engine tick while the miner process is alive. `node_ready`: synced AND an executed tip.
|
||||
pub fn tick(&mut self, now_s: f64, node_ready: bool) -> Action {
|
||||
let Some(started) = self.started_s else { return Action::None };
|
||||
if let Some(t) = self.worker_restart_since {
|
||||
// the miner is restarting its worker: its own guards own the card until the worker is ready
|
||||
|
|
@ -223,14 +364,32 @@ impl CardWatch {
|
|||
}
|
||||
return Action::None;
|
||||
}
|
||||
let last = self.last_status_s.unwrap_or(started);
|
||||
let node_synced = node_ready;
|
||||
if !node_synced {
|
||||
// a miner is judged only while the node is ready: with no template it cannot print status, so the silence
|
||||
// clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026)
|
||||
self.started_s = Some(now_s);
|
||||
if self.last_status_s.is_some() {
|
||||
self.last_status_s = Some(now_s);
|
||||
}
|
||||
self.zero_since = None;
|
||||
return Action::None;
|
||||
}
|
||||
// the status clocks start when the worker reports its program loaded (MF-4), never before; loading itself
|
||||
// is bounded by LOAD_S
|
||||
let Some(ready_at) = self.ready_s else {
|
||||
if now_s - started > LOAD_S {
|
||||
return self.escalate(format!("the worker did not load its program within {} s of starting", LOAD_S as u64));
|
||||
}
|
||||
return Action::None;
|
||||
};
|
||||
if self.last_status_s.is_none() && now_s - ready_at > START_S {
|
||||
return self.escalate(format!("the worker gave no status within {} s of loading its program", START_S as u64));
|
||||
}
|
||||
let last = self.last_status_s.unwrap_or(ready_at);
|
||||
if now_s - last > NO_STATUS_S {
|
||||
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
|
||||
}
|
||||
if !node_synced {
|
||||
// a zero rate while the node syncs is expected; the timer starts again once it is synced
|
||||
self.zero_since = None;
|
||||
}
|
||||
if node_synced && self.ready {
|
||||
if let Some(z) = self.zero_since {
|
||||
if now_s - z >= ZERO_RATE_S {
|
||||
|
|
@ -253,10 +412,15 @@ impl NodeWatch {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
/// `silent_s`: seconds since the last reading (or since the node started, when it never answered). Returns the
|
||||
/// delay in seconds before the restart when one is due.
|
||||
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool) -> Option<u64> {
|
||||
if !ours || accepted_recent || silent_s < NODE_SILENT_S {
|
||||
/// `silent_s`: seconds since the node's last sign of life (a watch reading, an exec status answer, an accepted
|
||||
/// block; or since the node started, when it never answered). `settled`: the node has been read as synced at
|
||||
/// least once since its start; before that its catch-up never counts, only `NODE_STARTUP_CAP_S` of total silence
|
||||
/// does. Returns the delay in seconds before the restart when one is due.
|
||||
pub fn tick(&mut self, now_s: f64, ours: bool, silent_s: f64, accepted_recent: bool, settled: bool) -> Option<u64> {
|
||||
if !ours || accepted_recent {
|
||||
return None;
|
||||
}
|
||||
if silent_s < if settled { NODE_SILENT_S } else { NODE_STARTUP_CAP_S } {
|
||||
return None;
|
||||
}
|
||||
self.restarts_s.retain(|t| now_s - *t <= NODE_WINDOW_S);
|
||||
|
|
@ -403,7 +567,10 @@ mod tests {
|
|||
#[test]
|
||||
fn parses_status_and_fault_lines() {
|
||||
let s = parse_status(STATUS_OK).unwrap();
|
||||
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true });
|
||||
assert_eq!(s, Status { hash_now: 124.10, mismatched: 0, faults: 0, restarts: 0, synced: true, template_wait_s: 0.0, template_ms: 0.0, identities_active: 0 });
|
||||
// a 0.3.20 miner waiting on a slow node (MF-5)
|
||||
let w = parse_status("1791151000.000 STATUS 'win-1' [worker]: 120s jobs=0 accepted=0 rejected=0 fee=0 mismatched=0 extra=0 rate=0.00 blocks/s hash=0.00 MH/s wall (0.00 MH/s inside jobs) now=0.00 MH/s wall (0.00 MH/s inside jobs, 0 jobs, seed walk 0 calls) template_age=0.00s synced=true idle=100.0% (last 10s: 100.0%) queued=0 restarts=0 faults=0 identities=24 accepted_by_identity=0 tip_age_s=0 template_wait=37s template_ms=8120 identities_active=1").unwrap();
|
||||
assert_eq!((w.template_wait_s, w.template_ms, w.identities_active), (37.0, 8120.0, 1));
|
||||
let m = parse_status(STATUS_MISMATCH).unwrap();
|
||||
assert_eq!((m.mismatched, m.faults, m.restarts), (3, 1, 1));
|
||||
assert_eq!(parse_status(STATUS_ZERO).unwrap().hash_now, 0.0);
|
||||
|
|
@ -422,6 +589,19 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn restart(a: &Action) -> (String, u64, u32) {
|
||||
match a {
|
||||
Action::Restart { reason, delay_s, attempt } => (reason.clone(), *delay_s, *attempt),
|
||||
Action::None => panic!("expected a restart, got None"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ladder() {
|
||||
assert_eq!((1..=6).map(retry_delay_s).collect::<Vec<_>>(), vec![10, 30, 120, 300, 300, 300]);
|
||||
assert_eq!(retry_delay_s(0), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_miner_is_left_alone() {
|
||||
let mut w = CardWatch::new();
|
||||
|
|
@ -431,61 +611,99 @@ mod tests {
|
|||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
}
|
||||
|
||||
/// the project lead's rule (7 October 2026): no fault is permanent. A zero rate restarts the miner on the ladder 10, 30,
|
||||
/// 120, 300, 300 ... s, the reason stays in plain words, and the hash resumes on its own when the worker is back.
|
||||
#[test]
|
||||
fn zero_rate_restarts_once_then_faults() {
|
||||
fn zero_rate_restarts_on_the_ladder_for_ever_and_the_hash_resumes() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 100.0);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
for t in [110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None, "under 60 s at {t}");
|
||||
let mut t = 110.0;
|
||||
let mut seen = Vec::new();
|
||||
for expect in [(10u64, 1u32), (30, 2), (120, 3), (300, 4), (300, 5), (300, 6)] {
|
||||
// the app restarts it after the delay; still zero: the next rung
|
||||
w.event(t, Event::Started);
|
||||
w.event(t + 2.0, Event::Ready);
|
||||
let mut a = Action::None;
|
||||
let mut k = 0.0;
|
||||
while a == Action::None && k < 200.0 {
|
||||
w.event(t + 10.0 + k, Event::Status(&z));
|
||||
a = w.tick(t + 10.0 + k, true);
|
||||
k += 10.0;
|
||||
}
|
||||
let (reason, delay, attempt) = restart(&a);
|
||||
assert!(reason.starts_with("hash rate 0 for 60 s"), "{reason}");
|
||||
assert!(!reason.contains("once already"), "the permanent state no longer exists: {reason}");
|
||||
assert_eq!((delay, attempt), expect, "rung {}", expect.1);
|
||||
seen.push(delay);
|
||||
t += 10.0 + k + delay as f64;
|
||||
}
|
||||
w.event(170.0, Event::Status(&z));
|
||||
let a = w.tick(170.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("hash rate 0 for 60 s")), "{a:?}");
|
||||
// the app restarted it; still zero: faulted, not restarted again
|
||||
w.event(172.0, Event::Started);
|
||||
w.event(174.0, Event::Ready);
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(240.0, Event::Status(&z));
|
||||
let a = w.tick(240.0, true);
|
||||
assert!(matches!(a, Action::Fault(ref r) if r.contains("restarted once already")), "{a:?}");
|
||||
assert!(w.faulted().is_some());
|
||||
// faulted stays: no more actions
|
||||
w.event(250.0, Event::Status(&z));
|
||||
assert_eq!(w.tick(260.0, true), Action::None);
|
||||
// the user changed the card's settings: a fresh start
|
||||
w.event(300.0, Event::Reset);
|
||||
assert!(w.faulted().is_none());
|
||||
assert_eq!(seen, vec![10, 30, 120, 300, 300, 300]);
|
||||
// the worker is healthy again: five healthy minutes and the ladder starts over at 10 s
|
||||
w.event(t, Event::Started);
|
||||
w.event(t + 2.0, Event::Ready);
|
||||
healthy(&mut w, t + 10.0, t + 320.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
let a = { let mut a = Action::None; let mut k = 0.0; while a == Action::None { w.event(t + 330.0 + k, Event::Status(&z)); a = w.tick(t + 330.0 + k, true); k += 10.0; } a };
|
||||
assert_eq!(restart(&a).1, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_rate_only_counts_with_a_synced_node_and_a_ready_worker() {
|
||||
fn zero_rate_only_counts_with_a_ready_node_and_a_ready_worker() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
let z = parse_status(STATUS_ZERO).unwrap();
|
||||
// not ready yet (program loading): no zero timer
|
||||
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] {
|
||||
for t in [10.0, 20.0, 30.0, 40.0, 50.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, true), Action::None);
|
||||
}
|
||||
w.event(82.0, Event::Ready);
|
||||
// node not synced: no zero timer either
|
||||
for t in [90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0] {
|
||||
w.event(52.0, Event::Ready);
|
||||
// node not ready (syncing, or no executed tip yet): no zero timer either
|
||||
for t in [60.0, 70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
assert_eq!(w.tick(t, false), Action::None);
|
||||
}
|
||||
assert_eq!(w.tick(170.0, true), Action::None, "the timer starts at the first zero status after ready");
|
||||
for t in [180.0, 190.0, 200.0, 210.0, 220.0, 230.0] {
|
||||
assert_eq!(w.tick(140.0, true), Action::None, "the timer starts at the first zero status after ready");
|
||||
for t in [150.0, 160.0, 170.0, 180.0, 190.0, 200.0] {
|
||||
w.event(t, Event::Status(&z));
|
||||
w.tick(t, true);
|
||||
}
|
||||
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
|
||||
assert!(matches!(w.tick(210.0, true), Action::Restart { .. }));
|
||||
}
|
||||
|
||||
/// PC 1, 7 October 2026: three workers started while the node caught up, printed nothing (no template), were
|
||||
/// restarted once and then marked faulted for good. Now: the silence clocks hold while the node is not ready, a
|
||||
/// restart the node explains does not climb the ladder once the node syncs, and nothing is ever permanent.
|
||||
#[test]
|
||||
fn a_worker_started_while_the_node_catches_up_is_never_faulted() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
// the node is not ready for ten minutes: no action, no climb
|
||||
let mut t = 1.0;
|
||||
while t < 600.0 {
|
||||
assert_eq!(w.tick(t, false), Action::None);
|
||||
t += 1.0;
|
||||
}
|
||||
// ready now, the worker has loaded but prints nothing: a restart after START_S, on rung 1
|
||||
w.event(t, Event::Ready);
|
||||
let mut a = Action::None;
|
||||
while a == Action::None && t < 700.0 {
|
||||
a = w.tick(t, true);
|
||||
t += 1.0;
|
||||
}
|
||||
let (reason, delay, attempt) = restart(&a);
|
||||
assert!(reason.starts_with("the worker gave no status within"), "{reason}");
|
||||
assert_eq!((delay, attempt), (10, 1));
|
||||
assert!(w.restarted_for_node());
|
||||
// the node drops back to catching up and syncs again: the ladder resets for a node-caused restart
|
||||
w.event(t, Event::NodeSynced);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
w.event(t + 10.0, Event::Started);
|
||||
w.event(t + 12.0, Event::Ready);
|
||||
healthy(&mut w, t + 20.0, t + 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -494,24 +712,64 @@ mod tests {
|
|||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
// the miner goes silent (a stopped process, a hung RPC)
|
||||
assert_eq!(w.tick(149.0, true), Action::None);
|
||||
let a = w.tick(151.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("no status line")), "{a:?}");
|
||||
let (reason, delay, _) = restart(&w.tick(151.0, true));
|
||||
assert!(reason.contains("no status line"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_status_from_the_start() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.tick(89.0, true), Action::None);
|
||||
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
|
||||
// loading: the status clock has not started; a worker that never loads is restarted at LOAD_S
|
||||
assert_eq!(w.tick(290.0, true), Action::None);
|
||||
let (reason, delay, _) = restart(&w.tick(301.0, true));
|
||||
assert!(reason.contains("did not load its program within 300 s"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
// loaded at 200 s (a slow self-test behind another card's export): the 60 s status clock starts there
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(200.0, Event::Ready);
|
||||
assert_eq!(w.tick(259.0, true), Action::None);
|
||||
let (reason, _, _) = restart(&w.tick(261.0, true));
|
||||
assert!(reason.contains("gave no status within 60 s of loading"), "{reason}");
|
||||
}
|
||||
|
||||
/// MF-4 (PC 1, 7 October 2026): a worker that fails its self-test is held for 30 minutes with the reason on its
|
||||
/// row, not restarted every few seconds; a driver change releases it; a miner in a crash loop climbs the ladder.
|
||||
#[test]
|
||||
fn self_test_failure_is_held_and_a_crash_loop_climbs_the_ladder() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
let (reason, delay, _) = restart(&w.event(3.0, Event::SelfTestFailed("3 of 96 vectors mismatched")));
|
||||
assert_eq!(reason, "not usable on this driver: 3 of 96 vectors mismatched");
|
||||
assert_eq!(delay, SELF_TEST_HOLD_S);
|
||||
assert!(w.driver_hold());
|
||||
w.event(100.0, Event::DriverChanged);
|
||||
assert!(!w.driver_hold());
|
||||
// a crash loop: exits 2 s after each start climb 10, 30, 120 s
|
||||
let mut w = CardWatch::new();
|
||||
let mut t = 0.0;
|
||||
let mut delays = Vec::new();
|
||||
for _ in 0..3 {
|
||||
w.event(t, Event::Started);
|
||||
let (reason, delay, _) = restart(&w.event(t + 2.0, Event::Exited(3)));
|
||||
assert!(reason.contains("exited with code 3"), "{reason}");
|
||||
delays.push(delay);
|
||||
t += 2.0 + delay as f64;
|
||||
}
|
||||
assert_eq!(delays, vec![10, 30, 120]);
|
||||
// an exit after a long healthy run is the engine's own jittered restart
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 600.0);
|
||||
assert_eq!(w.event(700.0, Event::Exited(3)), Action::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_miners_own_worker_restart_is_not_doubled() {
|
||||
// The gfx1036 fault: the miner prints WORKER FAULT, kills the worker, restarts it 2 s later; STATUS lines in
|
||||
// between say now=0. The app must not restart the miner on top of that.
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
|
|
@ -531,40 +789,86 @@ mod tests {
|
|||
w.event(t as f64, Event::Status(&z));
|
||||
assert_eq!(w.tick(t as f64, true), Action::None);
|
||||
}
|
||||
let a = w.tick(1091.0, true);
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("did not come back within 180 s")), "{a:?}");
|
||||
let (reason, delay, _) = restart(&w.tick(1091.0, true));
|
||||
assert!(reason.contains("did not come back within 180 s"), "{reason}");
|
||||
assert_eq!(delay, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_43_once_then_faulted() {
|
||||
fn exit_43_is_a_restart_on_the_ladder() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
w.event(70.0, Event::WorkerRestart("cpu re-check: 3 consecutive mismatches (mismatched=3 in this run): the worker computes a wrong program"));
|
||||
let a = w.event(75.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Restart(ref r) if r.contains("gave up") && r.contains("wrong program")), "{a:?}");
|
||||
w.event(80.0, Event::Started);
|
||||
let a = w.event(300.0, Event::Exited(43));
|
||||
assert!(matches!(a, Action::Fault(_)), "{a:?}");
|
||||
// an ordinary crash is the engine's own jittered restart, not the watchdog's
|
||||
let (reason, delay, attempt) = restart(&w.event(75.0, Event::Exited(43)));
|
||||
assert!(reason.contains("gave up") && reason.contains("wrong program"), "{reason}");
|
||||
assert_eq!((delay, attempt), (10, 1));
|
||||
w.event(85.0, Event::Started);
|
||||
let (_, delay, attempt) = restart(&w.event(300.0, Event::Exited(43)));
|
||||
assert_eq!((delay, attempt), (30, 2));
|
||||
// an exit 5 s after the start is the crash-loop class (MF-4): the ladder, not the 5 to 60 s jitter
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
assert_eq!(w.event(5.0, Event::Exited(1)), Action::None);
|
||||
assert!(matches!(w.event(5.0, Event::Exited(1)), Action::Restart { delay_s: 10, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_healthy_minutes_renew_the_budget() {
|
||||
fn template_timeouts_are_the_miners_heartbeat() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
assert!(matches!(w.tick(100.0, true), Action::Restart(_)));
|
||||
w.event(101.0, Event::Started);
|
||||
w.event(103.0, Event::Ready);
|
||||
healthy(&mut w, 110.0, 420.0);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
// the node stops answering templates for four minutes while the app still reads it as ready
|
||||
let mut t = 70.0;
|
||||
while t < 300.0 {
|
||||
w.event(t, Event::TemplateTimeout);
|
||||
assert_eq!(w.tick(t + 1.0, true), Action::None, "a heartbeat at {t} is not silence");
|
||||
t += 5.0;
|
||||
}
|
||||
assert!(w.templates_blocked());
|
||||
healthy(&mut w, 310.0, 400.0);
|
||||
assert!(!w.templates_blocked());
|
||||
}
|
||||
|
||||
/// MF-5 (PC 1, 7 October 2026): the node answered templates past 5 s; the miner now prints STATUS every interval
|
||||
/// with template_wait= while it waits, and the watchdog measures the worker, never the node.
|
||||
#[test]
|
||||
fn a_slow_node_never_faults_the_card() {
|
||||
let mut w = CardWatch::new();
|
||||
w.event(0.0, Event::Started);
|
||||
w.event(2.0, Event::Ready);
|
||||
healthy(&mut w, 10.0, 60.0);
|
||||
let slow = Status { hash_now: 0.0, template_wait_s: 8.0, template_ms: 8120.0, identities_active: 1, synced: true, ..Default::default() };
|
||||
let mut t = 70.0;
|
||||
while t < 700.0 {
|
||||
w.event(t, Event::Status(&slow));
|
||||
assert_eq!(w.tick(t, true), Action::None, "waiting on the node at {t} is not a fault");
|
||||
t += 10.0;
|
||||
}
|
||||
assert!(w.templates_blocked());
|
||||
healthy(&mut w, 710.0, 800.0);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
// a second incident later is again a restart, not a fault
|
||||
assert!(matches!(w.tick(520.0, true), Action::Restart(_)));
|
||||
}
|
||||
|
||||
/// MF-14: known-failed first (an export that never returns), then the known-good shapes.
|
||||
#[test]
|
||||
fn an_export_that_never_returns_is_named_and_given_up_within_two_intervals() {
|
||||
// the PC 2 shape: 18 minutes on "exporting" behind another card's export; with a 30 s interval the row
|
||||
// names the blocker at 30 s and the wait ends at 60 s
|
||||
assert_eq!(export_wait(31.0, 30.0, "another card's export holds the pack lock (Intel Arc B580, 31 s)"), ExportWait::Say("exporting this hour's program: 31 s so far (another card's export holds the pack lock (Intel Arc B580, 31 s))".into()));
|
||||
match export_wait(1080.0, 30.0, "") {
|
||||
ExportWait::GiveUp(r) => { assert!(r.starts_with("the program export did not return in 1080 s (the export has not returned)")); assert!(r.ends_with("retries on its own interval")); }
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(export_wait(60.0, 30.0, "the node is not at the epoch yet"), ExportWait::GiveUp(_)));
|
||||
// a normal export (5 to 25 s) says nothing
|
||||
assert_eq!(export_wait(5.0, 30.0, ""), ExportWait::Waiting);
|
||||
assert_eq!(export_wait(25.0, 120.0, ""), ExportWait::Waiting);
|
||||
// a longer rung widens the wait, never below 30 s
|
||||
assert_eq!(export_wait(100.0, 120.0, "x"), ExportWait::Waiting);
|
||||
assert!(matches!(export_wait(125.0, 120.0, "x"), ExportWait::Say(_)));
|
||||
assert!(matches!(export_wait(45.0, 10.0, "x"), ExportWait::Say(_)), "the floor is 30 s even on the 10 s rung");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -575,18 +879,26 @@ mod tests {
|
|||
w.event(30.0, Event::Stopped);
|
||||
assert_eq!(w.tick(500.0, true), Action::None);
|
||||
assert_eq!(w.event(500.0, Event::Exited(0)), Action::None);
|
||||
w.event(600.0, Event::Reset);
|
||||
assert_eq!(w.watchdog_restarts(), 0);
|
||||
}
|
||||
|
||||
/// The node's catch-up never counts against its watchdog (PC 1, 7 October 2026: a 40-second restart loop).
|
||||
#[test]
|
||||
fn node_watch_restarts_a_silent_node_with_growing_delay() {
|
||||
fn node_watch_waits_out_a_catch_up_and_restarts_a_dead_node_with_growing_delay() {
|
||||
let mut n = NodeWatch::new();
|
||||
assert_eq!(n.tick(100.0, true, 119.0, false), None);
|
||||
assert_eq!(n.tick(100.0, false, 500.0, false), None, "an external node is never restarted");
|
||||
assert_eq!(n.tick(100.0, true, 500.0, true), None, "our block was accepted in the last minute: the node is alive");
|
||||
assert_eq!(n.tick(100.0, true, 120.0, false), Some(3));
|
||||
assert_eq!(n.tick(300.0, true, 120.0, false), Some(12));
|
||||
assert_eq!(n.tick(500.0, true, 120.0, false), Some(48));
|
||||
// catching up (never read as synced since its start): 25 minutes of silence is not a restart
|
||||
assert_eq!(n.tick(100.0, true, 1500.0, false, false), None);
|
||||
// a node that never answers at all: restarted at the 30-minute cap
|
||||
assert_eq!(n.tick(100.0, true, 1801.0, false, false), Some(3));
|
||||
let mut n = NodeWatch::new();
|
||||
assert_eq!(n.tick(100.0, true, 119.0, false, true), None);
|
||||
assert_eq!(n.tick(100.0, false, 500.0, false, true), None, "an external node is never restarted");
|
||||
assert_eq!(n.tick(100.0, true, 500.0, true, true), None, "our block was accepted in the last minute: the node is alive");
|
||||
assert_eq!(n.tick(100.0, true, 120.0, false, true), Some(3));
|
||||
assert_eq!(n.tick(300.0, true, 120.0, false, true), Some(12));
|
||||
assert_eq!(n.tick(500.0, true, 120.0, false, true), Some(48));
|
||||
assert_eq!(n.restarts_in_window(), 3);
|
||||
assert_eq!(n.tick(2000.0, true, 120.0, false), Some(3), "the window passed");
|
||||
assert_eq!(n.tick(2000.0, true, 120.0, false, true), Some(3), "the window passed");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
app/igneum-app/ui/VERSION
Normal file
1
app/igneum-app/ui/VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.2
|
||||
|
|
@ -14,23 +14,30 @@
|
|||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/Unbounded-700.woff2) format('woff2')}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(fonts/Unbounded-900.woff2) format('woff2')}
|
||||
|
||||
/* scene-tokens:start (scene/tokens.css, written by tools/scene/sync.mjs; edit the source, never this block) */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ink-2:#C9C7C2;--ash:#9A9A9E;--included:#3B7DD8;--excluded:#B0362B;--ember-ink:#0C0C0E}
|
||||
:root[data-theme="light"]{--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#D0420D;--ember-hi:#E04A14;--molten:#B8731F;--bone:#16161A;--ink-2:#3C3C42;--ash:#6B6B70;--included:#2A62B8;--excluded:#B0362B;--ember-ink:#FFFFFF}}
|
||||
/* scene-tokens:end */
|
||||
:root{
|
||||
/* colour, dark */
|
||||
--obsidian:#0C0C0E;--graphite:#16161A;--row:#111114;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
|
||||
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--nvidia:#8BE37A;--intel:#7CC4FF;--rail-bg:#111114;--hover:rgba(255,255,255,.04);--top-bg:rgba(12,12,14,.86);--shadow:rgba(0,0,0,.6);--scrim:rgba(12,12,14,.72);--ok:#FFB35C;
|
||||
/* type scale */
|
||||
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:17px;--t-num:24px;--t-hero:34px;--t-h2:28px;--t-h1:44px;
|
||||
/* spacing scale */
|
||||
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
|
||||
--gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4);
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
|
||||
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px}
|
||||
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px;
|
||||
/* the chain scene's inclusion colours (EMBER 02 live-dag.js reads them from :root) */
|
||||
}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){
|
||||
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}}
|
||||
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}}
|
||||
:root[data-theme="light"]{
|
||||
--obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF;
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}
|
||||
|
||||
--ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--intel:#1C6FD6;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
|
||||
|
|
@ -61,7 +68,7 @@ input,textarea{font-variant-numeric:tabular-nums}
|
|||
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
|
||||
.btn.ghost{border-color:transparent;color:var(--ink-2)}
|
||||
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||
|
|
@ -77,7 +84,7 @@ body.mac .rail-brand{padding-top:30px}
|
|||
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
||||
.nav:hover{background:var(--hover);color:var(--bone)}
|
||||
.nav:hover svg{color:var(--ink-2)}
|
||||
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
|
||||
.nav.on{background:var(--hover);border-color:var(--line);color:var(--bone);font-weight:600}
|
||||
.nav.on svg{color:var(--ember)}
|
||||
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
||||
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
||||
|
|
@ -99,13 +106,15 @@ body.has-rail .top{left:var(--rail)}
|
|||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
||||
body.has-rail .top{align-items:baseline;padding-top:19px}
|
||||
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
||||
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
||||
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
|
||||
.pill{display:inline-flex;align-items:baseline;gap:var(--s-2);font-family:var(--sans);font-size:var(--t-base);font-weight:600;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:5px 12px 5px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;justify-content:center;line-height:1.3}
|
||||
.pill .dot{position:relative;top:-1px}
|
||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.pill.warn{color:var(--ember)}
|
||||
.pill.warn{color:var(--ember);border-color:var(--ember-40)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
|
||||
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
|
||||
.warn .dot,.dot.bad{background:var(--ember);animation:none}
|
||||
|
|
@ -116,26 +125,30 @@ body.has-rail .top{left:var(--rail)}
|
|||
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
|
||||
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
||||
body.has-rail .notices{left:var(--rail)}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
|
||||
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
|
||||
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
|
||||
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-3);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--row);border-bottom:1px solid var(--line);box-shadow:inset 0 1px 0 var(--ember);font-size:var(--t-base);line-height:1.4;color:var(--ash)}
|
||||
.notice-dot{width:7px;height:7px;border-radius:50%;background:var(--ember);flex:0 0 7px;display:inline-block}
|
||||
.notice.bad .notice-text,.notice.update-urgent .notice-text{color:var(--bone)}
|
||||
.notice.update-urgent .notice-text{font-weight:600}
|
||||
.notice .notice-text b{color:var(--bone);font-weight:600}
|
||||
.notice-text{flex:1 1 320px;min-width:0}
|
||||
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
||||
.notice-actions:empty{display:none}
|
||||
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
|
||||
.notice.update-urgent .notice-close{color:#fff}
|
||||
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
|
||||
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||
.notice-more{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||
.notice-more:hover{color:var(--bone);border-color:var(--line-2)}
|
||||
.notice-more .chev{width:8px;height:8px;transform:rotate(45deg) translateY(-2px)}
|
||||
.notice-more.open .chev{transform:rotate(225deg) translateY(-2px)}
|
||||
.notice-detail{flex-basis:100%;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);white-space:normal;word-break:break-word;margin-top:-2px;user-select:text;-webkit-user-select:text}
|
||||
.notice .prog{flex-basis:100%;height:2px;background:var(--line);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
||||
|
||||
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
|
||||
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
|
||||
body.has-rail .ask-wrap{left:var(--rail)}
|
||||
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
|
||||
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--row);border:1px solid var(--line);border-radius:14px;padding:12px 16px;box-shadow:inset 0 1px 0 var(--ember),0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
|
||||
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
|
||||
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
|
||||
.ask.inline{box-shadow:inset 0 1px 0 var(--ember);background:var(--row);margin-top:var(--s-3);animation:none}
|
||||
|
||||
/* screens and pages */
|
||||
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
|
||||
|
|
@ -143,7 +156,7 @@ main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overfl
|
|||
body.has-rail main{left:var(--rail)}
|
||||
body.drawer-open main{bottom:var(--drawer-h)}
|
||||
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="region"] #screen-region,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
|
||||
#screen-dashboard{padding:22px 0 32px}
|
||||
|
|
@ -186,11 +199,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
|
||||
/* GPU rows (first run) */
|
||||
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
|
||||
.gpu-row.off{opacity:.72}
|
||||
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 44px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.badge.apple{color:var(--bone)}
|
||||
.badge.nvidia{color:var(--nvidia)}
|
||||
.badge.amd{color:var(--ember)}
|
||||
.gpu-row.off>.badge,.gpu-row.off>.switch,.gpu-row.off .info>:not(.driver){opacity:.72}
|
||||
.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)}
|
||||
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap}
|
||||
|
|
@ -214,7 +223,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
||||
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
|
||||
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
|
||||
.toggle-big.stop .ring{background:var(--obsidian);border:1px solid var(--line-2);color:var(--ember)}
|
||||
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
||||
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
|
||||
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
||||
|
|
@ -246,15 +255,16 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.feed>div:last-child{border-bottom:0}
|
||||
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build,.feed .k.info{color:var(--ash)}
|
||||
.feed .k{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--molten);margin-right:10px;position:relative;top:-1px}
|
||||
.feed .k.error,.feed .k.warn,.feed .k.block{background:var(--ember)}
|
||||
.feed .k.build,.feed .k.info{background:var(--line-2)}
|
||||
.feed .k.proving{background:var(--nvidia)}
|
||||
|
||||
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
|
||||
the tune line under; the details under that */
|
||||
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.gpu-line{border:1px solid var(--line);border-radius:var(--row-r);background:var(--row);min-width:0}
|
||||
.gpu-line.off{opacity:.62}
|
||||
.gpu-line.off .gl-main,.gpu-line.off .gl-tune,.gpu-line.off .gl-details{opacity:.62}
|
||||
.gpu-line.open{border-color:var(--line-2)}
|
||||
.gl-main{display:grid;grid-template-columns:44px minmax(150px,1.2fr) auto auto;align-items:center;gap:var(--s-4);padding:12px 14px}
|
||||
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
|
|
@ -282,6 +292,21 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.gl-tune{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);border-top:1px solid var(--line)}
|
||||
.gl-tune .t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gl-tune .n{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.gl-driver{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;padding:8px 14px 10px 72px;font-size:var(--t-base);color:var(--ink-2);border-top:1px solid var(--line)}
|
||||
.gl-driver .t{flex:1 1 320px;min-width:0;color:var(--bone)}
|
||||
.gl-driver .n{flex-basis:100%;font-size:var(--t-sm);line-height:1.4}
|
||||
.gl-driver.offer .t,.gl-driver.reboot .t{font-weight:600}
|
||||
.gl-driver.error .t{color:var(--ember)}
|
||||
.gl-driver.note{color:var(--ash)}
|
||||
.gl-driver.note .t{color:var(--ink-2);font-weight:400}
|
||||
.gl-driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
|
||||
.gl-driver .bar b{display:block;height:100%;background:var(--ember);transition:width .4s}
|
||||
.gpu-row .driver{margin-top:6px;font-size:var(--t-base);color:var(--bone);display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
|
||||
.gpu-row .driver .n{flex-basis:100%;font-size:var(--t-sm);color:var(--ash);line-height:1.4}
|
||||
.gpu-row .driver.error .t{color:var(--ember)}
|
||||
.gpu-row .driver.note{color:var(--ink-2);font-weight:400}
|
||||
.gpu-row .driver .bar{flex-basis:100%;height:4px;border-radius:2px;background:var(--line-2);overflow:hidden;display:block}
|
||||
.gpu-row .driver .bar b{display:block;height:100%;background:var(--ember)}
|
||||
.gl-tune.running{color:var(--molten)}
|
||||
.gl-tune.stopped,.gl-tune.paused{color:var(--ember)}
|
||||
.gl-tune .bar{flex-basis:100%;height:3px;border-radius:2px;background:var(--line);overflow:hidden;display:block}
|
||||
|
|
@ -349,18 +374,26 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
|
||||
#s-jobs-history tr.running td{color:var(--molten)}
|
||||
.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.clock-card{border:1px solid var(--line);background:var(--row);box-shadow:inset 0 1px 0 var(--ember);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{box-shadow:inset 0 1px 0 var(--molten)}
|
||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* earnings: money first */
|
||||
.money{display:flex;flex-direction:column;margin-bottom:var(--s-2)}
|
||||
.money-row{display:flex;align-items:baseline;gap:var(--s-3);padding:8px 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
|
||||
.money-row:last-child{border-bottom:0}
|
||||
.money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px}
|
||||
.money-row .v.small{font-size:var(--t-num)}
|
||||
.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0}
|
||||
/* earnings (earnings-tidy, 7 October 2026): the day rate first in ember with its reason line, the run line, then a quiet
|
||||
row of three (weight, electricity, lifetime) in the totals' idiom, the dev-fee switch last */
|
||||
.earn{display:flex;flex-direction:column;margin-bottom:var(--s-3)}
|
||||
.earn-head{display:flex;flex-direction:column;gap:4px;padding-bottom:var(--s-3);border-bottom:1px solid var(--line)}
|
||||
.earn-head .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;letter-spacing:-.01em;color:var(--ember);text-wrap:balance}
|
||||
.earn-head .s,.earn-run .s{font-size:var(--t-base);color:var(--ash);line-height:1.45;min-width:0}
|
||||
.earn-run{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap;padding:var(--s-3) 0;border-bottom:1px solid var(--line)}
|
||||
.earn-run .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.15;white-space:nowrap}
|
||||
.earn-three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--s-4);padding-top:var(--s-3)}
|
||||
.earn-cell{display:flex;flex-direction:column;gap:3px;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
||||
.earn-cell:first-child{padding-left:0;border-left:0}
|
||||
.earn-cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
|
||||
.earn-cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.earn-cell .s{font-size:var(--t-sm);color:var(--ash);line-height:1.4}
|
||||
@media (max-width:720px){.earn-head .v{font-size:var(--t-h2)}.earn-three{grid-template-columns:1fr;gap:var(--s-2)}.earn-cell{padding-left:0;border-left:0;border-top:1px solid var(--line);padding-top:var(--s-2)}.earn-cell:first-child{border-top:0;padding-top:0}}
|
||||
|
||||
/* prove */
|
||||
.tier-list{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column;gap:6px;font-size:var(--t-md);color:var(--ink-2)}
|
||||
|
|
@ -372,7 +405,7 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
||||
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
||||
.option:hover{border-color:var(--line-2)}
|
||||
.option.on{border-color:var(--ember);background:var(--ember-12)}
|
||||
.option.on{border-color:var(--ember);background:var(--row)}
|
||||
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
|
||||
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
|
||||
.option.on .radio{border-color:var(--ember)}
|
||||
|
|
@ -384,6 +417,15 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text}
|
||||
.addr-input.short{width:100px;flex:0 0 100px}
|
||||
.addr-input:focus{outline:none;border-color:var(--ember)}
|
||||
/* Ember Heat (mission item 7): the region list, the restricted line, the heat line on the strip and the rows, the rent row */
|
||||
.addr-input.select{appearance:none;-webkit-appearance:none;max-width:360px;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);background-position:calc(100% - 20px) 50%,calc(100% - 14px) 50%;background-size:6px 6px,6px 6px;background-repeat:no-repeat;cursor:pointer}
|
||||
.addr-input.select option{background:var(--graphite);color:var(--bone)}
|
||||
.restricted{font-size:var(--t-md);line-height:1.5;color:var(--bone);background:var(--ember-12);border:1px solid var(--ember-40);border-left:3px solid var(--ember);border-radius:10px;padding:10px 14px;max-width:64ch;margin:0}
|
||||
.heat-line{font-family:var(--mono);font-size:var(--t-sm)}
|
||||
.heat-line.heat,.line-text.ok{color:var(--molten)}
|
||||
.heat-line.rest,.line-text.rest{color:var(--ash)}
|
||||
.field.grow{flex:1 1 260px;min-width:0}
|
||||
.gl-tune .n.heat{color:var(--molten)}
|
||||
.option:not(.on) .addr-input{display:none}
|
||||
.err{font-size:var(--t-base);color:var(--ember)}
|
||||
|
||||
|
|
@ -440,18 +482,21 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
|||
.row{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row .addr-input{margin-top:0;min-width:0}
|
||||
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4}
|
||||
/* the custom switch (Prove page fix, 7 October 2026): the label is the positioned ancestor, the native input is hidden
|
||||
the accessible way (1 px, clipped, still focusable, the label association kept), the knob lives inside the track at
|
||||
both states; the size class is the switch's own (big), never the DAG legend's swatch class */
|
||||
.switch{display:flex;align-items:flex-start;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:9px 0;line-height:1.4;position:relative}
|
||||
.switch+.switch{border-top:1px solid var(--line)}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
.switch input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
|
||||
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease;margin-top:1px}
|
||||
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:#F4F1EC;transition:transform .15s ease}
|
||||
.switch input:checked+.track{background:var(--ember)}
|
||||
.switch input:checked+.track::after{transform:translateX(18px)}
|
||||
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
|
||||
.switch input:disabled+.track{opacity:.4}
|
||||
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.lg .track::after{width:24px;height:24px}
|
||||
.switch.lg input:checked+.track::after{transform:translateX(22px)}
|
||||
.switch.big .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.big .track::after{width:24px;height:24px}
|
||||
.switch.big input:checked+.track::after{transform:translateX(22px)}
|
||||
.sw-text{min-width:0}
|
||||
.sw-text b{font-weight:600}
|
||||
.sw-text .dim{font-size:var(--t-base)}
|
||||
|
|
@ -506,8 +551,8 @@ body.drawer-drag main{pointer-events:none}
|
|||
.log .src.app{color:var(--ember)}
|
||||
.log .src.watch{color:var(--ash)}
|
||||
.log .e,.log .e .src{color:var(--ember)}
|
||||
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
|
||||
.log .ln.hit{background:var(--hover);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||
.log mark{background:transparent;color:var(--bone);text-decoration:underline;text-decoration-color:var(--ember);text-underline-offset:2px}
|
||||
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
|
||||
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
|
||||
.log-jump:hover{border-color:var(--molten)}
|
||||
|
|
@ -547,7 +592,6 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
h1{font-size:40px}
|
||||
.totals{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--s-3)}
|
||||
.tot:nth-child(3){padding-left:0;border-left:0}
|
||||
.money-row .v{min-width:0}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
:root{--rail:0px;--gutter:var(--s-4);--bottom:64px}
|
||||
|
|
@ -588,8 +632,9 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
.disclose-right .dim{display:none}
|
||||
.lead-row{flex-direction:column}
|
||||
.step{padding:32px 0}
|
||||
.money-row .v{font-size:var(--t-num)}
|
||||
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||
.notice{padding-left:var(--s-4);padding-right:var(--s-2);gap:var(--s-2)}
|
||||
.notice-text{flex-basis:160px}
|
||||
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||
}
|
||||
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
|
||||
|
|
@ -605,3 +650,227 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
|||
.drawer-head{padding-bottom:6px}
|
||||
.toggle-big{min-height:96px}
|
||||
}
|
||||
|
||||
/* miner-ui-4 (6 October 2026): Overview and Cards. The Overview hero (the project lead's pick, C): the fleet rate at 84 px on a
|
||||
graphite-to-obsidian fade with W, £ a day and blocks beside it, Start/Stop as the full-width bar under it. */
|
||||
.hero-row{grid-template-columns:1fr}
|
||||
.totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
|
||||
.tot{border-left:0;padding-left:0}
|
||||
.tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
|
||||
.tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
|
||||
.tot:first-child .s{font-size:var(--t-md)}
|
||||
.tot .v{font-size:30px}
|
||||
.tot .v.ask-price{font-size:var(--t-md);padding:9px 0 8px}
|
||||
.toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
|
||||
.toggle-big .ring{margin:0}
|
||||
.toggle-big .ts{margin-left:auto}
|
||||
/* the chain scene: the site's live-dag.js in a 220 px box, the legend words under it, the blocks strip as the fallback */
|
||||
.dag-wrap{position:relative}
|
||||
#dag-live{display:block;width:100%;height:220px;border-radius:10px;background:var(--obsidian);border:1px solid var(--line)}
|
||||
#dag{height:120px;margin-bottom:0}
|
||||
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
|
||||
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
|
||||
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
|
||||
.legend span{display:inline-flex;align-items:center;gap:7px}
|
||||
/* the DAG legend swatches, scoped to the legend (a bare .lg reached label.switch.lg until 7 October 2026) */
|
||||
/* the key's swatches (key-22, 7 October 2026): the entries, order and tokens come from IgneumDag.legend; a swatch takes its
|
||||
colour from --lg (set by renderLegend to the entry's token) and its shape from the class, so no state is coloured here */
|
||||
.legend .lg{--lg:var(--ash);width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--lg);background:var(--lg-fill,var(--row));position:relative;flex:none}
|
||||
.legend .lg.faded{opacity:.45}
|
||||
.legend .lg.circle{border-radius:50%;box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
|
||||
.legend .lg.ringed{box-shadow:0 0 0 2px color-mix(in srgb,var(--lg) 40%,transparent)}
|
||||
.legend .lg.tick{border-color:var(--line-2)}
|
||||
.legend .lg.tick::after{content:"\2713";position:absolute;left:0;top:-4px;font-size:11px;line-height:1;color:var(--lg)}
|
||||
/* Cards: the Tuning strip above the list */
|
||||
.tune-strip{padding:var(--s-4) var(--card-pad)}
|
||||
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
|
||||
.ts-row .goal-line{flex:0 1 auto}
|
||||
.ts-saving{font-size:var(--t-base);color:var(--molten);font-weight:500;margin-left:auto}
|
||||
.ts-saving:empty{display:none}
|
||||
.tune-strip .line-text{margin-top:var(--s-2);font-size:var(--t-base);color:var(--ash)}
|
||||
.tune-strip .switch{padding-bottom:0}
|
||||
/* the Ember layer on a row: the flame mark, the saving, the sparkline, the curve */
|
||||
.gl-tune{align-items:center}
|
||||
.flame{flex:0 0 16px;display:inline-block}
|
||||
.flame .fo{fill:none;stroke:var(--ash);stroke-width:7}
|
||||
.flame .ff{fill:var(--ember)}
|
||||
.flame.running .ff{fill:var(--molten)}
|
||||
.flame.measured .ff{fill:var(--ash)}
|
||||
.flame.idle .ff,.flame.paused .ff,.flame.stopped .ff{fill:none}
|
||||
.gl-tune.tuned .t{color:var(--ink-2)}
|
||||
.gl-tune .save,.gl-details .save{color:var(--molten);white-space:nowrap}
|
||||
.num .k.spark{display:inline-flex;align-items:center;gap:6px}
|
||||
.spark{display:inline-block;vertical-align:middle}
|
||||
.spark path{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.spark circle{fill:var(--ember)}
|
||||
.curve-wrap{display:flex;flex-direction:column;gap:6px;max-width:420px}
|
||||
.curve-svg{width:100%;height:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px}
|
||||
.curve-svg .ln{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg circle{fill:var(--ember)}
|
||||
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
|
||||
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
|
||||
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
|
||||
.gl-details .ctl .seg{justify-self:start}
|
||||
@media (max-width:1180px){
|
||||
.totals{grid-template-columns:1fr 1fr 1fr 1fr}
|
||||
.tot:first-child .v{font-size:64px}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.totals{grid-template-columns:1fr 1fr;row-gap:var(--s-4)}
|
||||
.tot:first-child{grid-column:1 / -1}
|
||||
.tot:nth-child(3){padding-left:0}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.tot:first-child .v{font-size:56px}
|
||||
.toggle-big .ts{display:none}
|
||||
#dag-live{height:170px}
|
||||
.ts-saving{margin-left:0}
|
||||
.legend{gap:6px 12px}
|
||||
.rail-foot .nav.small{padding:6px 4px}
|
||||
}
|
||||
|
||||
/* ---- miner-ui-5: the miner's first month: the count-up, the block card, the ladder strip, the rungs, the timeline ---- */
|
||||
.wait-card{background:var(--row);padding:16px 20px}
|
||||
.wait-row{display:flex;align-items:center;gap:var(--s-4)}
|
||||
.wait-ring{position:relative;width:52px;height:52px;flex:0 0 52px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.wait-ring svg{position:absolute;inset:0;width:52px;height:52px;transform:rotate(-90deg)}
|
||||
.wait-ring .track{fill:none;stroke:var(--line);stroke-width:3}
|
||||
.wait-ring .arc{fill:none;stroke:var(--molten);stroke-width:3;stroke-linecap:round;stroke-dasharray:97.4;stroke-dashoffset:97.4;transition:stroke-dashoffset .6s ease}
|
||||
.wait-ring .pct{font-size:var(--t-xs);color:var(--molten)}
|
||||
.wait-text .t{font-size:var(--t-md);color:var(--bone)}
|
||||
.wait-text .s{font-size:var(--t-sm);color:var(--ash);margin-top:2px}
|
||||
.block-card{position:relative;background:linear-gradient(135deg,var(--graphite),var(--row));border-color:var(--line-2);box-shadow:inset 0 1px 0 var(--ember),0 18px 50px var(--shadow);user-select:text;-webkit-user-select:text}
|
||||
.bc-head{display:flex;align-items:center;gap:var(--s-3);margin-bottom:var(--s-2)}
|
||||
.bc-when{margin-left:auto;font-size:var(--t-xs);color:var(--ash)}
|
||||
.bc-close{position:static;margin-left:4px}
|
||||
.bc-title{font-size:var(--t-h1);letter-spacing:-.02em;margin-bottom:var(--s-2)}
|
||||
.bc-title.small{font-size:var(--t-h2)}
|
||||
.bc-line{font-size:var(--t-lg);color:var(--ink-2);margin:2px 0}
|
||||
.bc-line.dim{color:var(--ash);font-size:var(--t-md)}
|
||||
.bc-hash{font-size:var(--t-sm);color:var(--ash);margin:8px 0 12px;word-break:break-all}
|
||||
.shard-card{box-shadow:inset 0 1px 0 var(--nvidia)}
|
||||
.ladder-strip{padding:14px 20px}
|
||||
.ls-rungs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.ls-rung{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.04em;color:var(--ash);padding:4px 10px 4px 6px;border:1px solid var(--line);border-radius:999px;white-space:nowrap}
|
||||
.ls-rung i{width:8px;height:8px;border-radius:50%;background:var(--line-2);display:inline-block}
|
||||
.ls-rung.done{color:var(--molten);border-color:var(--molten-40)}
|
||||
.ls-rung.done i{background:var(--molten)}
|
||||
.ls-rung.now{color:var(--bone);border-color:var(--ember-40)}
|
||||
.ls-rung.now i{background:var(--ember);box-shadow:0 0 0 3px var(--ember-12)}
|
||||
.ls-rung.off{opacity:.55}
|
||||
.ls-line{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap}
|
||||
.ls-line .t{font-size:var(--t-md);color:var(--bone);font-weight:500}
|
||||
.ls-line .s{font-size:var(--t-sm);min-width:0}
|
||||
.ls-line .btn{margin-left:auto}
|
||||
.rungs{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column}
|
||||
.rung{display:grid;grid-template-columns:22px 1fr;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line)}
|
||||
.rung:last-child{border-bottom:0}
|
||||
.rg-dot{width:12px;height:12px;border-radius:50%;background:var(--line-2);margin-top:5px;border:2px solid transparent;position:relative}
|
||||
.rung.done .rg-dot{background:var(--molten)}
|
||||
.rung.now .rg-dot{background:var(--ember);box-shadow:0 0 0 4px var(--ember-12)}
|
||||
.rung.off .rg-dot{background:transparent;border-color:var(--line-2)}
|
||||
.rg-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.rg-name{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.rg-line{font-size:var(--t-lg);color:var(--bone)}
|
||||
.rung.done .rg-line{color:var(--molten)}
|
||||
.rung.next .rg-line,.rung.off .rg-line{color:var(--ash)}
|
||||
.rg-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.45}
|
||||
.rung .bar{display:block;height:3px;border-radius:2px;background:var(--line);overflow:hidden;margin-top:6px;max-width:320px}
|
||||
.rung .bar b{display:block;height:100%;background:var(--ember)}
|
||||
.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
|
||||
.timeline li{display:grid;grid-template-columns:140px auto 1fr;gap:var(--s-3);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);color:var(--ash);font-size:var(--t-md)}
|
||||
.timeline li:last-child{border-bottom:0}
|
||||
.timeline li.done{color:var(--bone)}
|
||||
.timeline .tl-at{font-size:var(--t-sm);color:var(--molten);white-space:nowrap}
|
||||
.timeline .tl-note{font-size:var(--t-sm);min-width:0}
|
||||
.num.ign .v{color:var(--molten)}
|
||||
@media (max-width:720px){.bc-title{font-size:var(--t-h2)}.timeline li{grid-template-columns:110px auto 1fr}.ls-line .btn{margin-left:0}}
|
||||
|
||||
/* ---- the full chain scene with the block inspector (EMBER 02; the words of the site's /live) ---- */
|
||||
.chain-full{margin-top:var(--s-3);border-top:1px solid var(--line);padding-top:var(--s-3)}
|
||||
.cf-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);flex-wrap:wrap;margin-bottom:var(--s-3)}
|
||||
.cf-right{display:inline-flex;align-items:center;gap:var(--s-2)}
|
||||
.zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||
.zoom button{background:transparent;border:0;color:var(--ink-2);font:400 13px/1 var(--mono);padding:7px 11px;cursor:pointer}
|
||||
.zoom button:hover{background:var(--hover)}
|
||||
.zoom span{font-size:var(--t-xs);color:var(--ash);padding:0 6px;min-width:44px;text-align:center}
|
||||
.cf-graph{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:var(--s-4)}
|
||||
.cf-scene{position:relative;min-width:0}
|
||||
.cf-scene canvas{display:block;width:100%;height:420px;border-radius:10px;background:var(--row);border:1px solid var(--line)}
|
||||
.cf-foot{margin-top:8px;font-size:var(--t-xs);color:var(--ash)}
|
||||
.inspector{min-width:0;font-size:var(--t-base);border-left:1px solid var(--line);padding-left:var(--s-4)}
|
||||
.inspector .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.chip{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);border:1px solid var(--line);border-radius:4px;padding:4px 8px;white-space:nowrap}
|
||||
.ititle{font-family:var(--sans);font-weight:500;font-size:20px;line-height:1.2;color:var(--bone);margin:6px 0 4px;letter-spacing:-.01em}
|
||||
.ihash{font-size:var(--t-xs);color:var(--ash);overflow-wrap:anywhere;margin-bottom:12px;user-select:text;-webkit-user-select:text}
|
||||
.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden}
|
||||
.proof-scene canvas{display:block;width:100%;height:100%}
|
||||
.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px}
|
||||
/* the inspector's shard track, scoped to its element (a bare .track reached every switch's track until 7 October 2026) */
|
||||
#i-track{display:flex;gap:3px;height:3px;margin-bottom:10px}
|
||||
#i-track i{flex:1 1 0;background:var(--line);border-radius:2px}
|
||||
#i-track i.proving{background:var(--ember)}#i-track i.verified{background:var(--bone)}#i-track i.paid{background:var(--molten)}
|
||||
.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto}
|
||||
.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)}
|
||||
.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)}
|
||||
.shards li.proving,.shards li.proving::before{color:var(--ember)}.shards li.verified,.shards li.verified::before{color:var(--bone)}.shards li.paid,.shards li.paid::before{color:var(--molten)}
|
||||
.shards li.muted{color:var(--ash)}
|
||||
.insp{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 10px;margin:0;border-top:1px solid var(--line);padding-top:6px;font-size:var(--t-xs)}
|
||||
.insp dt{color:var(--ash);padding:5px 0}
|
||||
.insp dd{margin:0;color:var(--bone);text-align:right;padding:5px 0;overflow-wrap:anywhere;min-width:0}
|
||||
.inspector .note{font-size:var(--t-sm);margin-top:12px;padding-top:10px;border-top:1px solid var(--line)}
|
||||
.inspector .top-gap{margin-top:8px}
|
||||
@media (max-width:900px){.cf-graph{grid-template-columns:1fr}.inspector{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:var(--s-3)}}
|
||||
|
||||
/* ---------- vendor marks (gpu-logos, 7 October 2026): a self-contained block, the last thing in the file ----------
|
||||
One simplified monochrome glyph per GPU vendor (View.vendorMark in app.js) in a soft rounded well: the vendor colour
|
||||
at low alpha behind it, a hairline ring in the same colour, the glyph in the full colour. The tokens are the
|
||||
module's own (--mark-*), one set per theme, so the light hex of every vendor reads at 3:1 or better on its well
|
||||
(view.test.mjs computes the ratio and checks the hex here). The ember accent is for state and never tints a brand.
|
||||
Nothing animates: hover and focus-within only deepen the ring. One mark per row, drawn by View.markHtml alone. */
|
||||
:root{--mark-nvidia:#8BE37A;--mark-nvidia-well:rgba(139,227,122,.14);--mark-nvidia-ring:rgba(139,227,122,.45);--mark-amd:#FF5A5A;--mark-amd-well:rgba(255,90,90,.14);--mark-amd-ring:rgba(255,90,90,.45);--mark-intel:#7CC4FF;--mark-intel-well:rgba(124,196,255,.14);--mark-intel-ring:rgba(124,196,255,.45);--mark-apple:#E6E3DD;--mark-apple-well:rgba(230,227,221,.14);--mark-apple-ring:rgba(230,227,221,.45);--mark-gpu:#9A9A9E;--mark-gpu-well:rgba(154,154,158,.14);--mark-gpu-ring:rgba(154,154,158,.45)}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}}
|
||||
:root[data-theme="light"]{--mark-nvidia:#2F8A22;--mark-nvidia-well:rgba(47,138,34,.1);--mark-nvidia-ring:rgba(47,138,34,.45);--mark-amd:#C41E2A;--mark-amd-well:rgba(196,30,42,.1);--mark-amd-ring:rgba(196,30,42,.45);--mark-intel:#1C6FD6;--mark-intel-well:rgba(28,111,214,.1);--mark-intel-ring:rgba(28,111,214,.45);--mark-apple:#4A4A50;--mark-apple-well:rgba(74,74,80,.1);--mark-apple-ring:rgba(74,74,80,.45);--mark-gpu:#6B6B70;--mark-gpu-well:rgba(107,107,112,.1);--mark-gpu-ring:rgba(107,107,112,.45)}
|
||||
.badge{width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;flex:0 0 44px;border:1px solid var(--mark-gpu-ring);color:var(--mark-gpu);background:var(--mark-gpu-well);box-shadow:0 0 0 0 transparent;transition:box-shadow .15s ease,border-color .15s ease}
|
||||
.badge svg{width:22px;height:22px;display:block}
|
||||
.badge.nvidia{color:var(--mark-nvidia);background:var(--mark-nvidia-well);border-color:var(--mark-nvidia-ring)}
|
||||
.badge.amd{color:var(--mark-amd);background:var(--mark-amd-well);border-color:var(--mark-amd-ring)}
|
||||
.badge.intel{color:var(--mark-intel);background:var(--mark-intel-well);border-color:var(--mark-intel-ring)}
|
||||
.badge.apple{color:var(--mark-apple);background:var(--mark-apple-well);border-color:var(--mark-apple-ring)}
|
||||
.badge.gpu{color:var(--mark-gpu);background:var(--mark-gpu-well);border-color:var(--mark-gpu-ring)}
|
||||
.gpu-row:hover .badge,.gpu-line:hover .badge,.gpu-row:focus-within .badge,.gpu-line:focus-within .badge{border-color:currentColor;box-shadow:0 0 0 3px var(--mark-gpu-well)}
|
||||
.gpu-row:hover .badge.nvidia,.gpu-line:hover .badge.nvidia,.gpu-row:focus-within .badge.nvidia,.gpu-line:focus-within .badge.nvidia{box-shadow:0 0 0 3px var(--mark-nvidia-well)}
|
||||
.gpu-row:hover .badge.amd,.gpu-line:hover .badge.amd,.gpu-row:focus-within .badge.amd,.gpu-line:focus-within .badge.amd{box-shadow:0 0 0 3px var(--mark-amd-well)}
|
||||
.gpu-row:hover .badge.intel,.gpu-line:hover .badge.intel,.gpu-row:focus-within .badge.intel,.gpu-line:focus-within .badge.intel{box-shadow:0 0 0 3px var(--mark-intel-well)}
|
||||
.gpu-row:hover .badge.apple,.gpu-line:hover .badge.apple,.gpu-row:focus-within .badge.apple,.gpu-line:focus-within .badge.apple{box-shadow:0 0 0 3px var(--mark-apple-well)}
|
||||
.badge.mini{width:26px;height:26px;border-radius:8px;flex:0 0 26px;display:inline-flex;vertical-align:middle;margin-right:8px}
|
||||
.badge.mini svg{width:15px;height:15px}
|
||||
@media (prefers-reduced-motion:reduce){.badge{transition:none}}
|
||||
/* the series line under the name: mono, small, quiet */
|
||||
.gen{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gpu-row .gen{margin-top:-2px}
|
||||
.gpu-line .who .gen{margin:-1px 0 1px}
|
||||
#bc-card{display:flex;align-items:center;min-width:0}
|
||||
#bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.help.ask-cur{color:var(--ember)}
|
||||
|
||||
/* scaling-21 (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the
|
||||
Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node
|
||||
card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same
|
||||
proportions, no type below 13 px, the live scene and the GPU marks keep their look; only paddings, gaps and the
|
||||
scene's height move. The other pages already fit their first object at 1280 by 800 and stay as they are. */
|
||||
#page-overview{gap:var(--s-3)}
|
||||
#page-overview .hero-row{gap:var(--s-2)}
|
||||
#page-overview .totals{padding:16px 22px 14px}
|
||||
#page-overview .tot .k{margin-top:2px}
|
||||
#page-overview .toggle-big{min-height:54px;padding:9px 20px}
|
||||
#page-overview .ladder-strip{padding:10px 16px}
|
||||
#page-overview .ls-rungs{margin-bottom:6px}
|
||||
#page-overview .dag-card{padding:16px 18px 14px}
|
||||
#page-overview .dag-card .card-head{margin-bottom:8px}
|
||||
#page-overview #dag-live{height:184px}
|
||||
#page-overview .legend{margin-top:8px}
|
||||
#page-overview .wait-card{padding:12px 20px}
|
||||
#page-overview .node-card{padding-top:18px}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
52
app/igneum-app/ui/fold.test.mjs
Normal file
52
app/igneum-app/ui/fold.test.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// node --test app/igneum-app/ui/fold.test.mjs (scaling-21, the project lead, 7 October 2026: "the miner needs some scaling so
|
||||
// more is visible in the initial window"). Measures the key elements of each page inside the viewport on the mock with
|
||||
// Playwright's Chromium, at 1280 by 800, 1440 by 900 and 1920 by 1080: the Overview's rate band, big button, ladder
|
||||
// strip and chain card must end above the fold, and the node card's first row must start above it; Cards shows its
|
||||
// first card row, Earnings its headline card, Prove its switch. Runs where the Playwright env is set (build-2:
|
||||
// `. /srv/builds/_bin/overlap/env.sh`), skips elsewhere: the Mac runs no Playwright suite (CLAUDE.md, 7 October 2026).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../../..');
|
||||
const require = createRequire(import.meta.url);
|
||||
let chromium = null;
|
||||
try { if (process.env.IGNEUM_PLAYWRIGHT_DIR) chromium = require(join(process.env.IGNEUM_PLAYWRIGHT_DIR, 'node_modules/playwright')).chromium; } catch (e) { chromium = null; }
|
||||
const SIZES = [[1280, 800], [1440, 900], [1920, 1080]];
|
||||
const ROW = 48; // the first row of a card: its head line with the padding above it
|
||||
|
||||
test('the first screen: the Overview above the fold at 1280 by 800, 1440 by 900 and 1920 by 1080; Cards, Earnings and Prove show their first object', { skip: chromium ? false : 'no Playwright env (IGNEUM_PLAYWRIGHT_DIR); runs on build-2' }, async () => {
|
||||
const port = 4480 + Math.floor(Math.random() * 400);
|
||||
const mock = spawn(process.execPath, [join(root, 'tools/ui-mock/server.mjs'), String(port)], { stdio: 'ignore' });
|
||||
try {
|
||||
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/t/mock/api/state`); if (r.ok) break; } catch (e) { } await new Promise((r) => setTimeout(r, 250)); }
|
||||
const browser = await chromium.launch();
|
||||
const bad = [];
|
||||
const rect = (page, sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight }; }, sel);
|
||||
for (const [w, h] of SIZES) {
|
||||
const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce', colorScheme: 'dark' });
|
||||
const open = async (scenario, p) => { await page.goto(`http://127.0.0.1:${port}/t/mock/?scenario=${scenario}&page=${p}&theme=dark`, { waitUntil: 'load' }); await page.waitForTimeout(1200); };
|
||||
const fits = async (sel, label, rowOnly) => { const r = await rect(page, sel); if (!r) { bad.push(`${w}x${h} ${label}: not found`); return; } const end = rowOnly ? r.top + ROW : r.bottom; if (!(r.top >= 0 && end <= r.inner)) bad.push(`${w}x${h} ${label}: ${rowOnly ? 'row ends at ' + end : 'ends at ' + r.bottom} of ${r.inner}`); };
|
||||
await open('live', 'overview');
|
||||
await fits('#totals', 'Overview rate band'); await fits('#btn-toggle', 'Overview big button'); await fits('#ladder-strip', 'Overview ladder strip'); await fits('#dag-card', 'Overview chain card'); await fits('#node-card', 'Overview node card first row', true);
|
||||
if (h >= 1080) await fits('#node-card', 'Overview node card whole at 1080');
|
||||
await open('rig', 'cards'); await fits('#tune-card', 'Cards tune strip'); await fits('#d-cards .gpu-line:first-child', 'Cards first card row');
|
||||
await open('ladder', 'earnings'); await fits('.earn', 'Earnings headline card');
|
||||
await open('ladder', 'prove'); await fits('#s-prove', 'Prove switch');
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
assert.deepEqual(bad, [], 'below the fold:\n' + bad.join('\n'));
|
||||
} finally { mock.kill(); }
|
||||
});
|
||||
|
||||
test('the type never drops below 13 px on the Overview density pass: no font-size under 13px inside the #page-overview rules', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const css = readFileSync(join(here, 'app.css'), 'utf8');
|
||||
const at = css.indexOf('/* scaling-21');
|
||||
assert.ok(at >= 0, 'the density block exists');
|
||||
const block = css.slice(at);
|
||||
for (const m of block.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)) assert.ok(parseFloat(m[1]) >= 13, 'font-size ' + m[1] + 'px in the density block');
|
||||
});
|
||||
150
app/igneum-app/ui/heat-region.test.mjs
Normal file
150
app/igneum-app/ui/heat-region.test.mjs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// node --test app/igneum-app/ui/heat-region.test.mjs (no dependencies; the pre-push gate runs it)
|
||||
// Ember Heat (mission item 7, docs/plans/ember-heat.md): the region prompt shows the right restricted entry for a Russian
|
||||
// region and nothing for the rest; the rent line reads the bench log's measured rate; the heat words name the duty and
|
||||
// the watts; the money symbol follows the region.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the region prompt shows the restricted entry for a Russian region, with the standing sentence first', () => {
|
||||
const line = V.restrictedLine('ru-mow');
|
||||
assert.ok(line.startsWith(V.RESTRICTED_SENTENCE), line);
|
||||
assert.equal(V.RESTRICTED_SENTENCE, 'Mining may be restricted where you are. You are responsible for checking.');
|
||||
assert.ok(line.includes('Moscow region from 15 August 2026 to 2032'), line);
|
||||
assert.ok(V.restrictedLine('ru').includes('ten regions from 1 January 2025 to 15 March 2031'), 'the other-region entry carries the ten-region ban');
|
||||
assert.ok(V.restrictedLine('ru').includes('does not carry the list of ten'), 'the app says what it does not know');
|
||||
for (const seasonal of ['ru-irk', 'ru-bur', 'ru-zab']) assert.ok(V.restrictedLine(seasonal).includes('seasonal mining ban'), seasonal);
|
||||
assert.ok(V.restrictedLine('cn').includes('illegal in China'), 'China is the other entry of future.md 8.3 item 7');
|
||||
});
|
||||
|
||||
test('no region outside the restricted list of future.md 8.3 gets a line', () => {
|
||||
const restricted = V.REGIONS.filter((r) => r.restricted).map((r) => r.code).sort();
|
||||
assert.deepEqual(restricted, ['cn', 'ru', 'ru-bur', 'ru-irk', 'ru-mow', 'ru-zab'], 'Russia (the ten regions, Moscow, the seasonal three) and China, nothing else');
|
||||
for (const code of ['gb', 'de', 'us', 'kz', 'py', 'ir', 'other', '']) assert.equal(V.restrictedLine(code), '', code);
|
||||
});
|
||||
|
||||
// the research file and the bench log are internal (tools/ci/export-exclude.txt): a tree without them skips the read
|
||||
const research = join(here, '../../../docs/analysis/mission/future.md');
|
||||
const benchLog = join(here, '../../../docs/bench-log.md');
|
||||
|
||||
test('the restricted entries are the ones the research file states (future.md section 4.4 and 8.3)', (t) => {
|
||||
if (!existsSync(research)) { t.skip('docs/analysis/mission/future.md is not in this tree'); return; }
|
||||
const future = readFileSync(research, 'utf8');
|
||||
assert.ok(future.includes('mining banned in 10 regions 1 Jan 2025 to 15 Mar 2031'), 'the ten-region ban and its dates');
|
||||
assert.ok(future.includes('Moscow region from 15 Aug 2026 to 2032'), 'the Moscow region entry and its dates');
|
||||
assert.ok(future.includes('seasonal bans in Irkutsk, Buryatia, Zabaikalsky'), 'the seasonal three');
|
||||
assert.ok(/China \| illegal; joint Notice 6 Feb 2026/.test(future), 'China');
|
||||
assert.ok(future.includes('"mining may be restricted where you are; you are responsible for checking"'), 'the standing sentence');
|
||||
});
|
||||
|
||||
test('the price prompt defaults from the public table and says it is typed, never fetched', () => {
|
||||
assert.equal(V.regionOf('gb').price, 26.32);
|
||||
assert.ok(V.regionOf('gb').source.includes('Ofgem'));
|
||||
assert.equal(V.regionOf('de').price, 38.69);
|
||||
assert.equal(V.regionOf('us').price, 17.7);
|
||||
assert.equal(V.regionOf('ru-mow').price, 0, 'no table price for a Russian region: the miner types one');
|
||||
assert.ok(V.priceNote('gb').includes('Typical: 26.32 p per kWh'));
|
||||
assert.ok(V.priceNote('gb').endsWith('Nothing is fetched.'));
|
||||
assert.ok(V.priceNote('ru').startsWith('No table price for this region.'));
|
||||
assert.ok(V.priceNote('').includes('Nothing is fetched.'));
|
||||
assert.ok(!src.includes('fetch(\'https://') && !src.includes('exchangerate'), 'the UI fetches no price and no rate');
|
||||
});
|
||||
|
||||
test('the money symbol follows the region: pounds, euros, dollars', () => {
|
||||
V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50');
|
||||
V.setRegion('de'); assert.equal(V.money(1.5), '€1.50'); assert.equal(V.currencyOf('de').minor, 'c');
|
||||
V.setRegion('us'); assert.equal(V.money(1.5), '$1.50');
|
||||
V.setRegion('ru-mow'); assert.equal(V.currencyOf('ru-mow').code, 'USD', 'a region without a table currency types US cents');
|
||||
V.setRegion(''); assert.equal(V.money(1.5), '£1.50', 'no region chosen: pounds, as before');
|
||||
});
|
||||
|
||||
test('the rent line reads the bench log: the measured USD per MH/s-hour in the app is the bench log\'s number', (t) => {
|
||||
if (!existsSync(benchLog)) { t.skip('docs/bench-log.md is not in this tree'); return; }
|
||||
const log = readFileSync(benchLog, 'utf8');
|
||||
const section = log.slice(log.lastIndexOf('## Rental cost of hash'));
|
||||
assert.ok(section.length > 0, 'the bench log carries a Rental cost of hash entry');
|
||||
const m = section.match(/USD ([0-9.]+) per MH\/s-hour/);
|
||||
assert.ok(m, 'the entry states USD x per MH/s-hour');
|
||||
assert.equal(V.RENT.usd_per_mhs_hour, parseFloat(m[1]), 'the app carries the bench log\'s measured rate; update RENT when the log moves');
|
||||
const date = section.match(/## Rental cost of hash, ([0-9]+ [A-Za-z]+ [0-9]{4})/);
|
||||
assert.ok(date, 'the entry is dated');
|
||||
assert.equal(V.RENT.measured, date[1]);
|
||||
});
|
||||
|
||||
test('the cost-against-rent line: a UK card at the Ofgem price is about 10x cheaper than rented hash (future.md 3.4)', () => {
|
||||
V.setRegion('gb');
|
||||
// 3.27 W per MH/s at 26.32 p: 0.0861 p per MH/s-hour, about USD 0.00114, 10x under 0.0117
|
||||
const r = V.rentLine([card({ power_w: 327, hash_now: 100 })], 26.32, 'gb');
|
||||
assert.equal(r.kind, 'line');
|
||||
assert.ok(Math.abs(r.cost - 0.0861) < 0.001, r.cost);
|
||||
assert.ok(Math.abs(r.usd - 0.00114) < 0.0001, r.usd);
|
||||
assert.equal(r.text, '0.086 p per MH/s-hour');
|
||||
assert.ok(r.ratio > 9.5 && r.ratio < 11, r.ratio);
|
||||
assert.ok(r.sub.includes('Rented hash: USD 0.0117 per MH/s-hour'), r.sub);
|
||||
assert.ok(r.sub.includes('6 October 2026'), r.sub);
|
||||
assert.ok(r.verdict.startsWith('Yours is 10'), r.verdict);
|
||||
assert.ok(r.sub.includes('about USD 0.0011'), r.sub);
|
||||
});
|
||||
|
||||
test('the cost-against-rent line says when rented hash undercuts the card, and what it needs when it cannot say', () => {
|
||||
// a 3080-class card at 12 GB and a dear tariff: 6 W per MH/s at 60 c (euro) = 0.36 c = USD 0.0039: still under the rent
|
||||
const under = V.rentLine([card({ power_w: 300, hash_now: 50 })], 60, 'de');
|
||||
assert.equal(under.kind, 'line');
|
||||
assert.ok(under.ratio > 2.5 && under.ratio < 3.5, under.ratio);
|
||||
// the day idle datacentre hash sets the rent at 0.00016 the line flips (the table in future.md 3.4): here, a card at
|
||||
// 20 W per MH/s on 100 c power = 2 c = USD 0.0216, dearer than the rent
|
||||
const dear = V.rentLine([card({ power_w: 400, hash_now: 20 })], 100, 'us');
|
||||
assert.equal(dear.kind, 'line');
|
||||
assert.ok(dear.ratio < 1, dear.ratio);
|
||||
assert.ok(dear.verdict.startsWith('Rented hash undercuts your card by'), dear.verdict);
|
||||
assert.equal(dear.sub.includes('about USD'), false, 'a dollar region needs no conversion');
|
||||
// no price: the W per MH/s and the ask
|
||||
const ask = V.rentLine([card()], 0, 'gb');
|
||||
assert.equal(ask.kind, 'price');
|
||||
assert.ok(ask.sub.includes('W per MH/s'), ask.sub);
|
||||
// not mining and never tuned: nothing to say, the rent still stated
|
||||
const none = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0 })], 26.32, 'gb');
|
||||
assert.equal(none.kind, 'none');
|
||||
assert.ok(none.sub.includes('USD 0.0117'));
|
||||
// not mining but tuned: the tune's point stands in, and the line says so
|
||||
const tuned = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0, sweep_watts: 290, sweep_mhs: 122.3 })], 26.32, 'gb');
|
||||
assert.equal(tuned.kind, 'line');
|
||||
assert.ok(tuned.sub.includes('(the last tune)'), tuned.sub);
|
||||
});
|
||||
|
||||
test('the heat words: the strip line names the set point, the room, the share of the hour and the watts', () => {
|
||||
const heating = { on: true, phase: 'heating', set_c: 20, room_c: 19.6, room_source: 'typed', duty: 0.6, duty_hour: 0.55, heat_w: 410, full_w: 410, until_s: 300 };
|
||||
assert.deepEqual(V.heatLine(heating), { text: 'Heat mode: holding 20.0 °C · room 19.6 °C · heating 55% of the time · 410 W of heat', tone: 'heat' });
|
||||
const resting = { ...heating, phase: 'resting', heat_w: 0, room_source: 'card', room_c: 19.2 };
|
||||
assert.deepEqual(V.heatLine(resting), { text: 'Heat mode: holding 20.0 °C · room about 19 °C · heating 55% of the time · resting, 410 W when heating', tone: 'rest' });
|
||||
assert.equal(V.heatLine({ on: false }).text, '');
|
||||
assert.ok(V.heatLine({ ...heating, room_source: 'none', duty_hour: 0 }).text.includes('no room reading yet · heating 60% of the time'));
|
||||
assert.equal(V.heatRowWords(heating), 'heat 55% · 410 W of heat');
|
||||
assert.equal(V.heatRowWords(resting), 'heat 55% · resting');
|
||||
assert.equal(V.heatRowWords({ on: false }), '');
|
||||
});
|
||||
|
||||
test('a resting card and the big button say heat mode, never a bare off', () => {
|
||||
const r = V.cardRow(card({ state: 'resting', hash_now: 0, message: 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' }));
|
||||
assert.equal(r.word, 'resting (heat mode)');
|
||||
assert.equal(r.sub, 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)', 'the engine\'s line is the row\'s second line');
|
||||
assert.equal(r.hash, '');
|
||||
const m = { state: 'waiting', paused: false, cards: [card({ state: 'resting', hash_now: 0 })], found: [] };
|
||||
const t = V.toggle(m, { synced: true }, { heat: { on: true, phase: 'resting', set_c: 20, until_s: 240 } });
|
||||
assert.equal(t.label, 'Stop mining');
|
||||
assert.equal(t.sub, 'resting · heat mode holds 20.0 °C, heats again in 4 min');
|
||||
assert.equal(t.act, 'pause');
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: m, node: { state: 'synced' }, heat: { on: true, phase: 'resting' } }), { text: 'Resting · heat mode', tone: '' });
|
||||
const e = V.ember(card({ tune_before_watts: 0 }), { settings: { sweep: true }, heat: { on: true, phase: 'heating', duty: 0.5, duty_hour: 0.5, heat_w: 410 } }, 1);
|
||||
assert.equal(e.heat, 'heat 50% · 410 W of heat');
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="overview">
|
||||
|
||||
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
|
||||
is a bottom tab bar. The setup screens have no rail. -->
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
||||
<button class="nav on" data-page="overview" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Overview</span></button>
|
||||
<button class="nav" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="11" rx="2"/><path d="M7 20h10M9 16v4M15 16v4M7 10h3M14 10h3"/></svg><span>Cards</span></button>
|
||||
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
|
||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Mine</h1>
|
||||
<h1 id="page-title-text">Overview</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
|
|
@ -48,8 +49,10 @@
|
|||
<!-- the status strip: one notice at a time (app.js, Notices) -->
|
||||
<div class="notices" id="notices" hidden>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">
|
||||
<i class="notice-dot" aria-hidden="true"></i>
|
||||
<span class="notice-text" id="notice-text"></span>
|
||||
<span class="notice-actions" id="notice-actions"></span>
|
||||
<button class="notice-more" id="notice-more" data-act="more" title="The technical line" aria-label="Show the technical line" aria-expanded="false" hidden><span class="chev"></span></button>
|
||||
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">×</button>
|
||||
<span class="notice-detail mono" id="notice-detail" hidden></span>
|
||||
<span class="prog" id="notice-prog" hidden><i></i></span>
|
||||
|
|
@ -101,7 +104,7 @@
|
|||
<!-- 2. cards -->
|
||||
<section class="screen" id="screen-cards">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 1 of 2</div>
|
||||
<div class="eyebrow">step 1 of 3</div>
|
||||
<h2>Your graphics card</h2>
|
||||
<p class="sub" id="cards-sub">Asking the graphics cards to report in.</p>
|
||||
<div class="cards" id="cards-list">
|
||||
|
|
@ -115,7 +118,7 @@
|
|||
</div>
|
||||
<p class="note" id="cards-note" hidden></p>
|
||||
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
|
||||
<p class="note" id="cards-help" hidden>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
|
||||
<p class="note" id="cards-help" hidden>A built-in GPU starts off. It is slow and shares the machine's memory.</p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
||||
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
||||
|
|
@ -123,10 +126,41 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. address -->
|
||||
<!-- 3. region and price (Ember Heat, mission item 7): the region list, a typed price never fetched, the restricted line -->
|
||||
<section class="screen" id="screen-region">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 2 of 3</div>
|
||||
<h2>Your electricity</h2>
|
||||
<p class="sub">Every money figure in the app comes from the price you type here, in your currency. Nothing is fetched.</p>
|
||||
<div class="field">
|
||||
<div class="k">region</div>
|
||||
<select class="addr-input select" id="region-select" aria-label="Region"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">currency</div>
|
||||
<select class="addr-input select" id="region-currency" aria-label="Currency"></select>
|
||||
<p class="help ask-cur" id="region-currency-ask" hidden></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">price per kWh</div>
|
||||
<div class="row">
|
||||
<input type="number" class="addr-input mono short" id="region-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="region-unit">pence per kWh</span>
|
||||
</div>
|
||||
<p class="help" id="region-note">Choose a region to see a typical price.</p>
|
||||
</div>
|
||||
<p class="restricted" id="region-restricted" hidden></p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-region-next">Continue</button>
|
||||
<button class="btn ghost" id="btn-region-back">Back</button>
|
||||
<button class="btn ghost" id="btn-region-skip">Skip for now</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. address -->
|
||||
<section class="screen" id="screen-address">
|
||||
<div class="step">
|
||||
<div class="eyebrow">step 2 of 2</div>
|
||||
<div class="eyebrow">step 3 of 3</div>
|
||||
<h2>Where should rewards go?</h2>
|
||||
<p class="sub">Every block this machine finds pays one address. Pick one way.</p>
|
||||
<div class="options">
|
||||
|
|
@ -143,7 +177,7 @@
|
|||
<span class="radio"></span>
|
||||
<span class="body">
|
||||
<span class="t">Use my own address</span>
|
||||
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
|
||||
<span class="s">Paste an address you control. It starts with 0x and has 40 characters after it.</span>
|
||||
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
|
||||
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
|
||||
</span>
|
||||
|
|
@ -157,11 +191,11 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. the dashboard: four pages behind the rail -->
|
||||
<!-- 5. the dashboard: four pages behind the rail -->
|
||||
<section class="screen" id="screen-dashboard">
|
||||
|
||||
<!-- Mine -->
|
||||
<section class="page" id="page-mine" data-page="mine">
|
||||
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
|
||||
<section class="page" id="page-overview" data-page="overview">
|
||||
<div class="clock-card" id="m-clock" hidden>
|
||||
<p class="clock-msg" id="m-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
|
||||
|
|
@ -182,24 +216,73 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
<!-- miner-ui-5: before the first block, the Poisson count-up; on a milestone, the block card (in place, never a dialog) -->
|
||||
<div class="card wait-card" id="first-wait" hidden>
|
||||
<div class="wait-row"><span class="wait-ring" aria-hidden="true"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15.5"></circle><circle class="arc" id="first-wait-arc" cx="18" cy="18" r="15.5"></circle></svg><span class="pct mono" id="first-wait-pct">0%</span></span>
|
||||
<div class="wait-text"><div class="t" id="first-wait-line">Waiting for your first block.</div><div class="s" id="first-wait-sub"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card block-card" id="block-card" hidden>
|
||||
<div class="bc-head"><span class="eyebrow ember" id="bc-eyebrow">your first block</span><span class="bc-when mono" id="bc-when"></span><button class="notice-close bc-close" id="bc-close" title="Dismiss" aria-label="Dismiss the block card">×</button></div>
|
||||
<h2 class="bc-title" id="bc-title">Block 0 is yours.</h2>
|
||||
<p class="bc-line" id="bc-card"></p>
|
||||
<p class="bc-line" id="bc-ign"></p>
|
||||
<p class="bc-line dim" id="bc-rank"></p>
|
||||
<p class="bc-hash mono" id="bc-hash"></p>
|
||||
<div class="row wrap"><button class="btn small primary" id="bc-open">Open in the explorer</button><button class="btn small" id="bc-save">Save the card</button><button class="btn small ghost" id="bc-copy">Copy the link</button><span class="note mono small" id="bc-saved"></span></div>
|
||||
<canvas id="bc-canvas" width="1200" height="630" hidden aria-hidden="true"></canvas>
|
||||
</div>
|
||||
<div class="card ladder-strip" id="ladder-strip" hidden>
|
||||
<div class="ls-rungs" id="ls-rungs" role="list" aria-label="The ladder"></div>
|
||||
<div class="ls-line"><span class="t" id="ls-line"></span><span class="s dim" id="ls-sub"></span><button class="btn tiny ghost" id="ls-more">The ladder</button></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tune-card">
|
||||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></div>
|
||||
<div class="goal-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<div class="card dag-card" id="dag-card">
|
||||
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-inspect" aria-expanded="false" aria-controls="chain-full">Inspect</button><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
|
||||
<!-- the compact scene (EMBER 02 live-dag.js, the site lane's module, byte-identical): the app feeds it its own api/live reply -->
|
||||
<div class="dag-wrap">
|
||||
<canvas id="dag-live" aria-label="The last minute of blocks: your lane and the other miner keys, the selected chain as one path, checkpoints as bands, your blocks framed"></canvas>
|
||||
<div class="dag-tip mono" id="dag-tip" hidden></div>
|
||||
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
|
||||
<canvas id="dag" aria-hidden="true" hidden></canvas>
|
||||
</div>
|
||||
<div class="legend" data-legend="mine"><span class="dim" id="dag-note"></span></div>
|
||||
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
|
||||
<div class="chain-full" id="chain-full" hidden>
|
||||
<div class="cf-bar">
|
||||
<div class="seg small" id="dag-filter" role="radiogroup" aria-label="Which blocks"><button class="seg-b on" data-filter="all" role="radio" aria-checked="true">All blocks</button><button class="seg-b" data-filter="chain" role="radio" aria-checked="false">Selected chain</button><button class="seg-b" data-filter="mine" role="radio" aria-checked="false">Your blocks</button></div>
|
||||
<span class="cf-right"><span class="zoom"><button type="button" id="z-out" aria-label="Wider window">−</button><span id="z-pct" class="mono">60 s</span><button type="button" id="z-in" aria-label="Narrower window">+</button></span><button class="btn tiny ghost on" id="dag-follow">Follow</button><button class="btn tiny ghost" id="dag-pause-full" aria-pressed="false">Pause</button></span>
|
||||
</div>
|
||||
<div class="cf-graph">
|
||||
<div class="cf-scene">
|
||||
<canvas id="dag-full" aria-label="The devnet's block graph, live: time-aligned miner lanes, every parent connection, the selected chain as one path, checkpoint bands, your blocks framed"></canvas>
|
||||
<div class="dag-tip mono" id="dag-tip-full" hidden></div>
|
||||
<span class="dag-chip mono" id="dag-chip-full" hidden>scroll to zoom · Esc to release</span>
|
||||
<div class="cf-foot mono">Click to inspect · Drag to explore</div>
|
||||
</div>
|
||||
<aside class="inspector" id="inspector" aria-live="polite">
|
||||
<div class="ih"><span class="eyebrow">Block inspector</span><span class="chip mono" id="i-kind">select a block</span></div>
|
||||
<h3 class="ititle" id="i-title">Select a block</h3>
|
||||
<div class="ihash mono" id="i-hash">Click the graph.</div>
|
||||
<div class="proof-scene"><canvas id="proof" aria-label="Select a block to see its shards"></canvas></div>
|
||||
<div class="proof-head"><span>Proof shards</span><span id="i-shard-count" class="mono">not set</span></div>
|
||||
<div class="track" id="i-track"></div>
|
||||
<ul class="shards mono" id="i-shards"><li class="muted">No block selected.</li></ul>
|
||||
<dl class="insp mono">
|
||||
<dt>Inclusion</dt><dd id="i-incl">not set</dd>
|
||||
<dt>Miner key</dt><dd id="i-miner">not set</dd>
|
||||
<dt>Blue score</dt><dd id="i-blue">not set</dd>
|
||||
<dt>DAA score</dt><dd id="i-daa">not set</dd>
|
||||
<dt>Parent links</dt><dd id="i-parents">not set</dd>
|
||||
<dt>Header time</dt><dd id="i-time">not set</dd>
|
||||
<dt>Checkpoint</dt><dd id="i-cp">not set</dd>
|
||||
<dt>Finality</dt><dd id="i-lock">not set</dd>
|
||||
</dl>
|
||||
<p class="note">Proof, chain selection and finality are separate states. None is inferred from a block’s age; a lock is drawn only when the observer reports one.</p>
|
||||
<p class="top-gap"><button class="btn tiny ghost" id="insp-close" hidden>Clear</button></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div class="card node-card" id="node-card">
|
||||
|
|
@ -210,25 +293,26 @@
|
|||
<div class="details" id="node-details" hidden>
|
||||
<div class="kv">
|
||||
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
|
||||
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
|
||||
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
|
||||
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
|
||||
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
|
||||
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
|
||||
<div><span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span></div>
|
||||
<div><span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span></div>
|
||||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the block DAG right now</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the chain right now</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain (the blue score)</span></div>
|
||||
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
|
||||
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s</span></div>
|
||||
<div><span class="k">last checkpoint</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s (a point the miners agree can never be undone)</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">rules digest</div>
|
||||
<div class="k">rules fingerprint</div>
|
||||
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
||||
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">next rule switch</div>
|
||||
<div class="k">next rule change</div>
|
||||
<div class="line-text" id="n-switch">none planned</div>
|
||||
<p class="help" id="n-switch-help"></p>
|
||||
<div class="switch-list mono" id="n-switches"></div>
|
||||
|
|
@ -238,22 +322,66 @@
|
|||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards: every card with Ember as its second layer -->
|
||||
<section class="page" id="page-cards" data-page="cards" hidden>
|
||||
<div class="card tune-strip" id="tune-card">
|
||||
<div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></div>
|
||||
<div class="ts-row">
|
||||
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line"></span>
|
||||
<span class="ts-saving" id="fleet-saving"></span>
|
||||
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
|
||||
</div>
|
||||
<p class="line-text" id="tune-schedule"></p>
|
||||
<p class="line-text heat-line" id="heat-line" hidden></p>
|
||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span></div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Earnings -->
|
||||
<section class="page" id="page-earnings" data-page="earnings" hidden>
|
||||
<div class="card">
|
||||
<div class="money">
|
||||
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
|
||||
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
|
||||
<!-- earnings-tidy (7 October 2026): the day rate first with its reason, the run line, then a quiet row of three
|
||||
(weight, electricity, lifetime), the dev-fee switch last. IGN only: no typed price anywhere. Every number names
|
||||
its source on hover (title). -->
|
||||
<div class="earn">
|
||||
<div class="earn-head"><span class="v" id="e-day">0 IGN a day</span><span class="s" id="e-day-sub">reading the network</span></div>
|
||||
<div class="earn-run"><span class="v" id="e-run">0 blocks this run</span><span class="s" id="e-run-sub"></span></div>
|
||||
<div class="earn-three">
|
||||
<div class="earn-cell"><span class="k">Weight</span><span class="v" id="e-rung">reading</span><span class="s" id="e-rung-sub"></span></div>
|
||||
<div class="earn-cell"><span class="k">Electricity</span><span class="v" id="e-cost">no draw</span><span class="s" id="e-cost-sub"></span></div>
|
||||
<div class="earn-cell"><span class="k">Lifetime</span><span class="v" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the people who make this app.</span></label>
|
||||
</div>
|
||||
|
||||
<div class="card" id="ladder-card">
|
||||
<div class="card-head"><h3>The ladder</h3><div class="head-right"><span class="eyebrow" id="ladder-source">reading</span></div></div>
|
||||
<p class="help">Weight is your blocks over the window, written into consensus. No points server, no badge: the chain computes every rung and your node reports it. Hover a line for the field it reads.</p>
|
||||
<ol class="rungs" id="rungs"></ol>
|
||||
</div>
|
||||
|
||||
<div class="card" id="timeline-card">
|
||||
<button class="disclose" id="tl-toggle" aria-expanded="false" aria-controls="tl-details"><span>Your first hour</span><span class="disclose-right"><span class="dim" id="tl-sum"></span><span class="chev" aria-hidden="true"></span></span></button>
|
||||
<div class="details" id="tl-details" hidden>
|
||||
<ol class="timeline" id="tl-steps"></ol>
|
||||
<p class="note" id="tl-note"></p>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the miner software's author</span></label>
|
||||
<p class="help" id="r-devfee-line"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -293,23 +421,36 @@
|
|||
|
||||
<!-- Prove -->
|
||||
<section class="page" id="page-prove" data-page="prove" hidden>
|
||||
<div class="card shard-card" id="shard-card" hidden>
|
||||
<div class="bc-head"><span class="eyebrow ember">proof shards</span><span class="bc-when mono" id="sc-when"></span></div>
|
||||
<h2 class="bc-title small" id="sc-title">Your card proved shard 0 of block 0.</h2>
|
||||
<p class="bc-line" id="sc-line"></p>
|
||||
<p class="bc-line dim" id="sc-sub"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Prove on this machine</h3>
|
||||
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
|
||||
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
|
||||
</div>
|
||||
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
||||
<label class="switch big" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
||||
</div>
|
||||
<div class="row" id="pv-instead-row" hidden>
|
||||
<div>
|
||||
<h3>Prove instead of mining</h3>
|
||||
<p class="help" id="pv-instead-help">A card under 12 GB holds the prover or the miner, never both. On, the miner stops on that card while it proves and comes back when proving stops.</p>
|
||||
</div>
|
||||
<label class="switch big" title="Prove instead of mining"><input type="checkbox" id="s-prove-instead" aria-label="Prove instead of mining"><span class="track"></span></label>
|
||||
</div>
|
||||
<ul class="tier-list" id="pv-tiers"></ul>
|
||||
<p class="line-text" id="pv-line"></p>
|
||||
<p class="note" id="pv-note"></p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">Takes about 20 minutes, once.</span></div>
|
||||
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
|
||||
<div class="details" id="pv-details" hidden>
|
||||
<div class="kv">
|
||||
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
|
||||
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments this machine proved</span></div>
|
||||
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments (runs of 8 blocks) this machine proved</span></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">shard program id</div>
|
||||
|
|
@ -318,7 +459,7 @@
|
|||
<div class="field">
|
||||
<div class="k">aggregator id</div>
|
||||
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
||||
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
|
||||
<p class="help">Every proof names the program that made it: the shard program for one shard, the aggregator for a whole segment. Other nodes accept a proof only from these two ids.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -330,29 +471,67 @@
|
|||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
|
||||
<div class="goal-row">
|
||||
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||
</div>
|
||||
<span class="goal-line" id="goal-line-2"></span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.</span><span class="dim" id="s-power-note"></span></span></label>
|
||||
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
|
||||
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
|
||||
<p class="line-text" id="s-tune-schedule"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Electricity</h3><span class="eyebrow">typed, never fetched</span></div>
|
||||
<div class="field">
|
||||
<div class="k">electricity price</div>
|
||||
<div class="k">region</div>
|
||||
<select class="addr-input select" id="s-region" aria-label="Region"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">currency</div>
|
||||
<select class="addr-input select" id="s-currency" aria-label="Currency"></select>
|
||||
<p class="help ask-cur" id="s-currency-ask" hidden></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">price per kWh</div>
|
||||
<div class="row">
|
||||
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses it.</span>
|
||||
<input type="number" class="addr-input mono short" id="s-price" min="0" max="100000" step="0.01" placeholder="26.32" aria-label="Electricity price per kWh"><span class="note" id="s-price-unit">pence per kWh</span>
|
||||
<button class="btn small" id="s-price-save">Save</button>
|
||||
</div>
|
||||
<p class="help" id="s-price-note"></p>
|
||||
</div>
|
||||
<p class="restricted" id="s-region-restricted" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Heat mode</h3><span class="eyebrow">Ember Heat</span></div>
|
||||
<label class="switch"><input type="checkbox" id="s-heat"><span class="track"></span><span class="sw-text"><b>Hold a room temperature</b> <span class="dim">The cards heat for a share of every 10 minutes and rest for the rest. The hash follows. In heat mode your card is an electric heater that also earns; whether it beats your boiler depends on the network and the price, shown live.</span></span></label>
|
||||
<p class="line-text" id="s-heat-line"></p>
|
||||
<div class="row wrap">
|
||||
<div class="field">
|
||||
<div class="k">hold</div>
|
||||
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-set" min="5" max="30" step="0.5" placeholder="19" aria-label="Set point in degrees"><span class="note">°C</span></div>
|
||||
</div>
|
||||
<div class="field grow">
|
||||
<div class="k">schedule</div>
|
||||
<div class="row"><input type="text" class="addr-input mono" id="s-heat-schedule" placeholder="06:00 20, 22:00 16" spellcheck="false" autocomplete="off" aria-label="Schedule"><button class="btn small" id="s-heat-save">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.</p>
|
||||
<div class="field">
|
||||
<div class="k">room now</div>
|
||||
<div class="row"><input type="number" class="addr-input mono short" id="s-heat-room" min="-20" max="50" step="0.1" placeholder="19.5" aria-label="Room temperature now"><span class="note">°C from your own thermometer</span><button class="btn small" id="s-heat-room-save">Use it</button></div>
|
||||
<p class="help" id="s-heat-room-note">A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.</p>
|
||||
</div>
|
||||
<p class="line-text" id="s-tune-schedule"></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>This machine</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-profile"><span class="track"></span><span class="sw-text"><b>Make my page public</b> <span class="dim" id="s-profile-text">Off: igneum.network/address keeps your page unlisted. Nothing about this machine is published.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
|
||||
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
|
||||
<div class="details indent" id="s-jobs-history" hidden></div>
|
||||
<p class="note mono small indent" id="s-jobs-key" hidden></p>
|
||||
|
|
@ -362,7 +541,7 @@
|
|||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="help">A label for you only.</p>
|
||||
<p class="help">A name for you only.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">appearance</div>
|
||||
|
|
@ -374,6 +553,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="s-interface-card">
|
||||
<div class="card-head"><h3>Interface</h3><span class="eyebrow" id="s-ui-eyebrow">built in</span></div>
|
||||
<p class="line-text" id="s-ui-line">Interface 1.0.0, built in</p>
|
||||
<p class="help" id="s-ui-help">Small changes to this window arrive over the air, signed by Igneum, without a new version of the app. A bundle that fails to load is rolled back to the built-in interface by itself.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-ui-builtin"><span class="track"></span><span class="sw-text"><b>Use the built-in interface</b> <span class="dim">Serves the interface this version of the app was built with, even when a newer one has arrived over the air.</span></span></label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
|
|
@ -401,8 +587,8 @@
|
|||
|
||||
<details class="card adv" id="s-advanced">
|
||||
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the node.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes are what lock the chain. Leave it on.</span></span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proofs without checking them</b> <span class="dim">Devnet only. The node includes proofs it never checked. Changing this restarts the node.</span></span></label>
|
||||
<div class="ask inline" id="ask-trust" hidden>
|
||||
<span class="ask-text" id="ask-trust-text"></span>
|
||||
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
|
||||
|
|
@ -494,6 +680,8 @@
|
|||
</div>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="live-dag.js"></script>
|
||||
<script src="proof-core.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
426
app/igneum-app/ui/live-dag.js
Normal file
426
app/igneum-app/ui/live-dag.js
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026). THE SHARED SOURCE: scene/live-dag.js; site/live-dag.js and
|
||||
* app/igneum-app/ui/live-dag.js are byte-equal copies written by tools/scene/sync.mjs (the gate refuses a drifted copy). Edit here.
|
||||
* 2.0.6 (7 Oct 2026): IgneumDag.shardWords(block, nowMs) is the one shard sentence (tooltip, /live inspector, app inspector); the
|
||||
* empty case reads from the block's facts (loading, excluded, not yet ordered, off the chain) instead of one fixed sentence.
|
||||
* 2.0.5 (key-22, 7 Oct 2026): IgneumDag.legend({mine}) and renderLegend(el,{mine}) are the one key both pages render; the app's
|
||||
* Included swatch had been ember while the scene draws included blocks in --included, and "pending" lower-case.
|
||||
* 2.0.4 (7 Oct 2026): the box's height follows the lanes. laneHeight (default 46 px, 40 on a phone, 26 compact) times the lanes
|
||||
* shown (at most maxLanes, narrowLanes on a phone, never under 2) plus the axis and padding is the scene's wanted height;
|
||||
* onSize(heightPx, {lanes, laneHeight, narrow, compact}) reports it whenever it changes, so a page can size the box, and with
|
||||
* autoHeight (true by default when the host set no CSS height on the canvas, forced either way by the option; never in
|
||||
* compact mode, the app's card keeps its own height) the renderer sets canvas.style.height itself and lets every lane through.
|
||||
* 2.0.3 (scene parity, 7 Oct 2026): (1) every push, size and theme change paints the current picture at once; only the motion
|
||||
* loop waits for a visible document and an intersecting canvas (a page that loads with document.hidden true, or whose embedder
|
||||
* never fires visibilitychange, showed a blank canvas while reporting live). (2) The narrow (phone) rule keys on the viewport
|
||||
* width, not the canvas width: a 640 px hero on a laptop is not a phone. The narrow options of 2.0.2 (narrow, narrowBreak 720,
|
||||
* narrowWindow 30, narrowLanes 4, narrowMinNode 11; on a phone the window shortens until the viewer sets one, four lanes, bigger
|
||||
* nodes, no hairlines but the selected chain, checkpoint labels as the percent) and the real-data presentation options of 2.0.1:
|
||||
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
|
||||
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
|
||||
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
|
||||
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
|
||||
* Existing mount/push options and public methods are retained; see README.md for additions.
|
||||
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
|
||||
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var instances = new WeakMap();
|
||||
var TAU = Math.PI * 2;
|
||||
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
|
||||
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
|
||||
function num(v) { return finite(v) ? v : null; }
|
||||
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
|
||||
function rgba(c, a) {
|
||||
var s = String(c).trim(), h = s.replace('#', ''), rgb;
|
||||
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
|
||||
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
|
||||
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
|
||||
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
|
||||
}
|
||||
function rounded(ctx, x, y, w, h, r) {
|
||||
r = Math.max(0, Math.min(r, w/2, h/2)); 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 cloneBlock(b) {
|
||||
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
|
||||
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
|
||||
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
|
||||
}
|
||||
function normalize(b) {
|
||||
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
|
||||
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
|
||||
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
|
||||
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
|
||||
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
|
||||
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
|
||||
}
|
||||
function mount(canvas, opts) {
|
||||
opts=opts||{};
|
||||
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
|
||||
if (instances.has(canvas)) instances.get(canvas).destroy();
|
||||
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
|
||||
var doc=canvas.ownerDocument, compact=!!opts.compact;
|
||||
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
|
||||
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
|
||||
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
|
||||
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
|
||||
// narrow (phones, under narrowBreak px, or opts.narrow true/false): window narrowWindow s until the viewer sets one, at most
|
||||
// narrowLanes lanes, nodes no smaller than narrowMinNode, hairlines off except the selected chain and the selected block,
|
||||
// checkpoint labels as the percent alone. The owner's call of 7 Oct 2026: faster drift, more gap, on a phone.
|
||||
var narrow=false,narrowBreak=finite(opts.narrowBreak)?opts.narrowBreak:720,narrowWindow=clamp(finite(opts.narrowWindow)?opts.narrowWindow:30,30,300),narrowLanes=clamp(finite(opts.narrowLanes)?opts.narrowLanes:4,2,12),narrowMinNode=finite(opts.narrowMinNode)?opts.narrowMinNode:11,baseWindow=0,windowTouched=false;
|
||||
// autoHeight: the host set no height on the canvas (its computed height is still the attribute's, 150 by default) and this
|
||||
// is not the compact card; read before size() touches the attribute
|
||||
var hostHeightSet=(function(){try{var cs=getComputedStyle(canvas),attr=canvas.getAttribute('height'),h=parseFloat(cs.height);return canvas.style.height!==''||!(finite(h)&&Math.round(h)===(attr===null?150:Number(attr)));}catch(e){return true;}})();
|
||||
var autoHeight=opts.autoHeight===true||(opts.autoHeight!==false&&!compact&&!hostHeightSet),laneHeightOpt=finite(opts.laneHeight)?opts.laneHeight:0,wantH=0,laneHNow=46;
|
||||
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
|
||||
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
|
||||
var reduced=mq?mq.matches:false, manualMotion=true;
|
||||
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
|
||||
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
|
||||
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
|
||||
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
|
||||
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
|
||||
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
|
||||
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
|
||||
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
|
||||
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
|
||||
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
|
||||
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
|
||||
var tip=opts.tooltip||null,chip=opts.chip||null;
|
||||
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
|
||||
function own(b){try{return !!mine(b);}catch(e){return false;}}
|
||||
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
|
||||
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
|
||||
function theme(){
|
||||
var s=getComputedStyle(doc.documentElement);
|
||||
function c(n,f){return s.getPropertyValue(n).trim()||f;}
|
||||
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
|
||||
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
|
||||
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
|
||||
surface:c('--graphite','#16161A')};
|
||||
paint();redraw();
|
||||
}
|
||||
function rightTime(){
|
||||
if(paused)return pausedRight;
|
||||
if(!following)return fixedRight;
|
||||
if(state!=='live' && staleRight!==null)return staleRight;
|
||||
return reduced||!manualMotion?reducedRight:Date.now()-lag;
|
||||
}
|
||||
function viewportW(){var w=root.innerWidth||(doc.documentElement?doc.documentElement.clientWidth:0);return w>0?w:W;}
|
||||
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
|
||||
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
|
||||
function setState(s,r){
|
||||
if(state===s && reason===r)return;
|
||||
if(s!=='live' && state==='live')staleRight=rightTime();
|
||||
state=s;reason=r||null;
|
||||
if(s==='live')staleRight=null;
|
||||
emit('onState',s,reason);redraw();
|
||||
}
|
||||
function layout(){
|
||||
if(!baseWindow)baseWindow=windowS;
|
||||
var wasNarrow=narrow;narrow=opts.narrow===true||(opts.narrow!==false&&!compact&&W>0&&viewportW()<narrowBreak);
|
||||
if(narrow!==wasNarrow&&!windowTouched){var nw=narrow?narrowWindow:baseWindow;if(nw!==windowS){windowS=nw;emit('onWindow',windowS);}}
|
||||
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
|
||||
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
|
||||
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
|
||||
var laneH=laneHeightOpt||(compact?26:narrow?40:46),cap=narrow?narrowLanes:maxLanes;laneHNow=laneH;
|
||||
var capacity=autoHeight?cap:Math.min(cap,Math.max(2,Math.floor((H-padT-padB)/laneH))),ids=Array.from(counts.keys());
|
||||
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
|
||||
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
|
||||
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
|
||||
var keep=lanes.filter(function(id){return chosen.includes(id);});
|
||||
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
|
||||
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
|
||||
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
|
||||
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
|
||||
// the wanted box height for these lanes (2.0.4): reported on change; applied here only under autoHeight
|
||||
var nextH=padT+padB+Math.max(2,lanes.length)*laneH;
|
||||
if(nextH!==wantH){wantH=nextH;emit('onSize',wantH,{lanes:lanes.length,laneHeight:laneH,narrow:narrow,compact:compact});if(autoHeight&&canvas.style.height!==wantH+'px')canvas.style.height=wantH+'px';}
|
||||
var now=performance.now();
|
||||
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
|
||||
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
|
||||
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
|
||||
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
|
||||
});
|
||||
}
|
||||
function size(){
|
||||
if(destroyed)return;
|
||||
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
|
||||
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
|
||||
W=nextW;H=nextH;dpr=nextDpr;
|
||||
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();paint();redraw();
|
||||
}
|
||||
function syncView(){
|
||||
var now=performance.now(),first=view.size===0, next=new Map();
|
||||
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
|
||||
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
|
||||
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
|
||||
next.set(hash,v);
|
||||
});
|
||||
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
|
||||
cps=latestCps.map(function(c){return Object.assign({},c);});
|
||||
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
|
||||
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
|
||||
layout();
|
||||
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
|
||||
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
|
||||
if(hover&&!view.has(hover))hover=null;
|
||||
queuedCount=0;
|
||||
}
|
||||
function push(d){
|
||||
if(destroyed)return false;
|
||||
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
|
||||
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
|
||||
}
|
||||
var now=Date.now(), incoming=[], bad=0;
|
||||
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
|
||||
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
|
||||
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
|
||||
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
|
||||
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
|
||||
// Bounded retention uses the newest observed header, not a local wall-clock guess.
|
||||
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
|
||||
omitted=0;
|
||||
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
|
||||
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
|
||||
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
|
||||
else setState('live',null);
|
||||
if(!paused)syncView();
|
||||
emit('onData',d);emit('onStats',stats());if(!paused){/*paint-on-push*/paint();redraw();}return true;
|
||||
}
|
||||
function stats(){
|
||||
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
|
||||
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
|
||||
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
|
||||
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
|
||||
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
|
||||
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,narrow:narrow,lanes:lanes.length,reducedMotion:reduced||!manualMotion,state:state,
|
||||
// the lane geometry (2.0.4), so a page that sizes its own box reads no constants
|
||||
laneHeight:laneHNow,padT:padT,padB:padB,capacity:narrow?narrowLanes:maxLanes,wantedHeight:wantH,autoHeight:autoHeight};
|
||||
}
|
||||
function poll(){
|
||||
if(destroyed||opts.poll===false||doc.hidden)return;
|
||||
if(inflight){pollAgain=true;return;}
|
||||
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
|
||||
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
|
||||
var url;
|
||||
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
|
||||
catch(e){finishError(e);return;}
|
||||
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
|
||||
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
|
||||
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
|
||||
}
|
||||
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
|
||||
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
|
||||
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
|
||||
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
|
||||
function draw(t){
|
||||
if(destroyed||!W||!H)return;
|
||||
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
|
||||
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
|
||||
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
|
||||
ctx.lineWidth=1;ctx.setLineDash([2,7]);
|
||||
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
|
||||
ctx.setLineDash([]);
|
||||
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
|
||||
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
|
||||
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
|
||||
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
|
||||
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
|
||||
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
|
||||
}
|
||||
});
|
||||
var cpLabels=[];
|
||||
cps.forEach(function(c){
|
||||
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
|
||||
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
|
||||
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
|
||||
if(!ref||ref.ts<left||ref.ts>rt)return;
|
||||
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
|
||||
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
|
||||
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
|
||||
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
|
||||
if(!compact && (narrow || W>510))cpLabels.push({x:x,cp:c,locked:locked});
|
||||
});
|
||||
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
|
||||
cpLabels.forEach(function(p){var pct=p.cp.fraction_total===null||p.cp.fraction_total===undefined?'':Math.round(p.cp.fraction_total*100)+'%',label=narrow?(pct||(p.locked?'LOCKED':'PENDING')):(p.locked?'LOCKED':'PENDING')+(pct?' '+pct:''),w=label.length*5.5+(narrow?14:22),x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
|
||||
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
|
||||
});
|
||||
// Animate lane transitions without changing their underlying key identity.
|
||||
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
|
||||
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
|
||||
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
|
||||
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
|
||||
if(!path&&!selectedEdge){if(narrow||pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
|
||||
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
|
||||
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
|
||||
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
|
||||
});});
|
||||
var S=compact?6.5:narrow?clamp(lh*.24,narrowMinNode,15):clamp(lh*.22,minNode,13);
|
||||
blocks.forEach(function(b){if(!b.on)return;
|
||||
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
|
||||
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
|
||||
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
|
||||
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
|
||||
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
|
||||
// a proven block glows and carries a ring, so the proof reads from across the room
|
||||
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
|
||||
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
|
||||
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
|
||||
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
|
||||
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
|
||||
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
|
||||
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
|
||||
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
|
||||
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
|
||||
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
|
||||
}
|
||||
}
|
||||
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
|
||||
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
|
||||
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
|
||||
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
|
||||
ctx.globalAlpha=1;
|
||||
});ctx.restore();
|
||||
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
|
||||
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
|
||||
var ticks=W<420?2:4;
|
||||
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
|
||||
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
|
||||
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
|
||||
}
|
||||
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
|
||||
if(!blocks.some(function(b){return b.on;})){
|
||||
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
|
||||
}
|
||||
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
|
||||
lastT=t;
|
||||
}
|
||||
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
|
||||
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
|
||||
// one synchronous frame of the current picture, whatever the document's visibility says (2.0.3)
|
||||
function paint(){if(destroyed||!W||!H)return;var t=performance.now();draw(t);lastPaint=t;}
|
||||
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
|
||||
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
|
||||
function words(b,nowMs){
|
||||
if(!b)return {title:'No block selected',lines:[]};
|
||||
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
|
||||
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
|
||||
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
|
||||
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
|
||||
'Shards: '+shardWords(b,nowMs).summary]};
|
||||
}
|
||||
function showTip(b){
|
||||
if(!tip)return;
|
||||
if(!b){tip.hidden=true;return;}
|
||||
var w=words(b),key=w.title+'|'+w.lines.join('|');
|
||||
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
|
||||
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
|
||||
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
|
||||
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
|
||||
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
|
||||
}
|
||||
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
|
||||
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
|
||||
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
|
||||
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowTouched=true;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
|
||||
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
|
||||
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
|
||||
function setPaused(p){p=!!p;if(paused===p)return;
|
||||
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
|
||||
else{paused=false;syncView();}
|
||||
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
|
||||
emit('onPause',paused);emit('onStats',stats());redraw();
|
||||
}
|
||||
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
|
||||
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
|
||||
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
|
||||
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
|
||||
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
|
||||
canvas.style.touchAction='pan-y';
|
||||
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
|
||||
on(canvas,'pointermove',function(ev){
|
||||
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
|
||||
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
|
||||
});
|
||||
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
|
||||
on(canvas,'pointercancel',function(){drag=null;});
|
||||
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
|
||||
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
|
||||
var touchStart=null;
|
||||
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
|
||||
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
|
||||
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
|
||||
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
|
||||
on(canvas,'keydown',function(ev){
|
||||
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
|
||||
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
|
||||
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
|
||||
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
|
||||
});
|
||||
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
|
||||
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
|
||||
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
|
||||
on(root,'resize',size);
|
||||
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
|
||||
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
|
||||
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
|
||||
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
|
||||
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
|
||||
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
|
||||
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
|
||||
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
|
||||
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
|
||||
}
|
||||
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getWantedHeight:function(){return wantH;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
|
||||
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
|
||||
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
|
||||
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
|
||||
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
|
||||
}
|
||||
// the shard sentence, once (7 Oct 2026, the fixed sentence the site's animation showed on every planless block): from the
|
||||
// block's own facts. No shards: an excluded block has nothing to prove, a pending block is not yet ordered, a blue block off
|
||||
// the selected chain gets no plan, a chain block under 10 s old is loading its plan, an older one says how long it has waited.
|
||||
// With shards: a planned shard with no prover is awaiting one (with the block's age), assigned, proving, verified, paid.
|
||||
function ageWord(sec){sec=Math.max(0,Math.round(sec));return sec<90?sec+' s':sec<5400?Math.round(sec/60)+' min':Math.round(sec/3600)+' h';}
|
||||
function shardWords(b,nowMs){
|
||||
var now=finite(nowMs)?nowMs:Date.now(),age=b&&finite(b.ts)?Math.max(0,(now-b.ts)/1000):0,sh=(b&&b.shards)||[];
|
||||
if(!sh.length){
|
||||
if(!b)return {summary:'',items:[],state:'none'};
|
||||
if(b.color==='red')return {summary:'excluded, nothing to prove',items:[],state:'excluded'};
|
||||
if(b.color!=='blue')return {summary:'not yet ordered',items:[],state:'pending'};
|
||||
if(!b.chain)return {summary:'off the selected chain, no shards planned',items:[],state:'none'};
|
||||
if(age<10)return {summary:'shard plan loading',items:[],state:'loading'};
|
||||
return {summary:'no shard plan after '+ageWord(age),items:[],state:'late'};
|
||||
}
|
||||
var items=sh.map(function(x,i){var st=x.state,w=st==='planned'?(x.prover?'assigned to '+x.prover:'awaiting a prover · '+ageWord(age)):st==='proving'?'proving'+(x.prover?' by '+x.prover:''):st==='verified'?'verified':st==='paid'?'paid'+(finite(x.payout)?' '+x.payout+' IGN':''):'unknown';return {i:i,state:st,word:w};});
|
||||
var n={paid:0,verified:0,proving:0,assigned:0,awaiting:0};
|
||||
sh.forEach(function(x){if(x.state==='planned'){if(x.prover)n.assigned++;else n.awaiting++;}else if(n[x.state]!==undefined)n[x.state]++;});
|
||||
var parts=[];if(n.paid)parts.push(n.paid+' paid');if(n.verified)parts.push(n.verified+' verified');if(n.proving)parts.push(n.proving+' proving');if(n.assigned)parts.push(n.assigned+' assigned');if(n.awaiting)parts.push(n.awaiting+' awaiting a prover · '+ageWord(age));
|
||||
return {summary:parts.join(', '),items:items,state:n.awaiting?'awaiting':n.proving?'proving':'done'};
|
||||
}
|
||||
// key-22 (7 October 2026, the app's key against /live): THE key, once. Entries in the order both
|
||||
// pages show them, each naming the token the scene draws that state with and the swatch's shape; the app passes
|
||||
// {mine:true} and gets "Your blocks" (ringed, molten) first. tools/scene/legend.test.mjs fails when a page's key or a
|
||||
// drawn colour drifts from this list. Shapes: square (outlined), circle (the checkpoint ring), tick (the proof mark),
|
||||
// ringed (the own-block halo); faded dims the swatch as the scene dims excluded blocks.
|
||||
function legend(o){
|
||||
var own={id:'you',label:'Your blocks',token:'--molten',shape:'ringed'};
|
||||
var list=[{id:'pending',label:'Pending',token:'--ash',shape:'square'},{id:'included',label:'Included',token:'--included',shape:'square'},
|
||||
{id:'excluded',label:'Excluded',token:'--excluded',shape:'square',faded:true},{id:'chain',label:'Selected chain',token:'--ember',shape:'square',fill:'--ember-12'},
|
||||
{id:'proven',label:'Proven',token:'--bone',shape:'tick'},{id:'locked',label:'Locked checkpoint',token:'--ember',shape:'circle'}];
|
||||
return (o&&o.mine?[own]:[]).concat(list);
|
||||
}
|
||||
// renders the key into el: <span><i class="lg <shape> [faded]" style="--lg:var(token)"></i>Label</span>…, keeping any
|
||||
// children the page already placed there (the app's source line) after the entries
|
||||
function renderLegend(el,o){
|
||||
if(!el)return [];var entries=legend(o),doc=el.ownerDocument,keep=Array.prototype.slice.call(el.childNodes).filter(function(n){return !(n.getAttribute&&n.getAttribute('data-lg'));});
|
||||
while(el.firstChild)el.removeChild(el.firstChild);
|
||||
entries.forEach(function(e){var sp=doc.createElement('span');sp.setAttribute('data-lg',e.id);var i=doc.createElement('i');i.className='lg '+e.shape+(e.faded?' faded':'');i.style.setProperty('--lg','var('+e.token+')');if(e.fill)i.style.setProperty('--lg-fill','var('+e.fill+')');sp.appendChild(i);sp.appendChild(doc.createTextNode(e.label));el.appendChild(sp);});
|
||||
keep.forEach(function(n){el.appendChild(n);});return entries;
|
||||
}
|
||||
root.IgneumDag={mount:mount,legend:legend,renderLegend:renderLegend,shardWords:shardWords,version:'2.0.6'};
|
||||
})(window);
|
||||
|
|
@ -96,7 +96,7 @@ test('update wording: available, downloading with percent, installing, failed wi
|
|||
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
|
||||
// updated: gone within 60 s of the new version starting
|
||||
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
|
||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
|
||||
assert.match(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59, now: 1_800_000_000 })).text, /^Updated to 0\.3\.6 at \d\d:\d\d\.$/);
|
||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
|
||||
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
|
||||
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
|
||||
|
|
@ -106,22 +106,26 @@ test('update wording: available, downloading with percent, installing, failed wi
|
|||
|
||||
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
|
||||
const r = jobNotice(run(), NOW);
|
||||
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
|
||||
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
|
||||
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
|
||||
assert.equal(r.text, 'A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8.');
|
||||
assert.equal(r.detail, 'shard benchmark · RESULT shard=2 prove_s=39.8', 'the technical line goes behind the chevron');
|
||||
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'A job from the team is running: shard benchmark, 6 min.');
|
||||
const d = jobNotice(fin('done'), NOW);
|
||||
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
|
||||
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
|
||||
assert.equal(d.key, 'job:done:job-6');
|
||||
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
|
||||
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
|
||||
const f = jobNotice(fin('failed'), NOW);
|
||||
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
|
||||
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
|
||||
assert.equal(f.text, 'A job from the team failed: build, after 32 min. The report did not upload.');
|
||||
assert.match(f.detail, /BUILD FAILED: error\[E0425\]: cannot find value `foo` · exit 1$/);
|
||||
assert.equal(f.tone, 'bad');
|
||||
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
|
||||
// no error line among the results: the summary is the cause
|
||||
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
|
||||
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
|
||||
assert.match(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, /^build: build exited with code 1 · exit 1$/);
|
||||
assert.equal(jobNotice(fin('timeout'), NOW).text, 'A job from the team ran out of time: build, after 32 min. The report did not upload.');
|
||||
assert.equal(N.jobWord('', 'update-now-0313-switch-d937c69d'), 'update check');
|
||||
assert.equal(N.jobWord('restart', 'restart-miners-0312'), 'restart');
|
||||
assert.equal(N.jobWord('collect', ''), 'log collection');
|
||||
assert.equal(N.jobWord('', 'ember-tune-pc1-5'), 'tuning check');
|
||||
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
|
||||
assert.equal(jobNotice(null, NOW), null);
|
||||
});
|
||||
|
|
|
|||
72
app/igneum-app/ui/proof-core.js
Normal file
72
app/igneum-app/ui/proof-core.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
|
||||
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
|
||||
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
|
||||
*/
|
||||
(function(root){
|
||||
'use strict';
|
||||
var mounted=new WeakMap(),TAU=Math.PI*2;
|
||||
function mount(canvas,opts){
|
||||
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
|
||||
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
|
||||
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
|
||||
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
|
||||
var phase=0,lastT=0;
|
||||
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
|
||||
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
|
||||
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
|
||||
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
|
||||
function iso(x,y,w,h,depth,fill,stroke){
|
||||
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
|
||||
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
|
||||
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
|
||||
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
|
||||
}
|
||||
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
|
||||
function draw(t){
|
||||
if(!W||!H||disposed)return;frames++;
|
||||
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
|
||||
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
|
||||
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
|
||||
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
|
||||
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
|
||||
// A stationary coordinate grid, not fabricated hashrate or work counters.
|
||||
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
|
||||
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
|
||||
var count=Math.min(12,shards.length);
|
||||
for(var i=0;i<count;i++){
|
||||
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
|
||||
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
|
||||
var midx=(px+x)/2,midy=(py+y)/2;
|
||||
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
|
||||
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
|
||||
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
|
||||
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
|
||||
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
|
||||
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
|
||||
}
|
||||
// Layered block core. Geometry does not imply three GPUs or three proofs.
|
||||
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
|
||||
ctx.lineWidth=1;
|
||||
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
|
||||
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
|
||||
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
|
||||
var topY=y-11*scale-lift;
|
||||
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
|
||||
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
|
||||
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
|
||||
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
|
||||
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
|
||||
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
|
||||
}
|
||||
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
|
||||
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
|
||||
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
|
||||
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
|
||||
function visibility(){if(document.hidden)stop();else kick();}
|
||||
function reduction(e){reduce=e.matches;stop();kick();}
|
||||
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
|
||||
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
|
||||
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
|
||||
}
|
||||
root.IgneumProof={mount:mount,version:'2.0.0'};
|
||||
})(window);
|
||||
49
app/igneum-app/ui/ui-ota.test.mjs
Normal file
49
app/igneum-app/ui/ui-ota.test.mjs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// node --test app/igneum-app/ui/ui-ota.test.mjs (no dependencies; the pre-push gate runs it)
|
||||
// The interface over the air (docs/plans/ui-ota.md): Settings > Interface's words and the gentle reload rule.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
test('the embedded interface says built in; an over-the-air one says so with its date', () => {
|
||||
assert.deepEqual(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true }), { line: 'Interface 1.0.0, built in', eyebrow: 'built in', help: '' });
|
||||
const w = V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', installed_at: Date.UTC(2026, 9, 7, 12) / 1000, confirmed: true });
|
||||
assert.equal(w.line, 'Interface 1.0.1, over the air, 7 Oct 2026');
|
||||
assert.equal(w.eyebrow, 'over the air');
|
||||
assert.equal(w.help, 'Built in: 1.0.0.');
|
||||
assert.equal(V.interfaceWords({}).line, '');
|
||||
assert.equal(readFileSync(join(here, 'VERSION'), 'utf8').trim(), '1.0.2', 'the embedded interface version is three-part and in ui/VERSION (1.0.2: the 0.3.22 tree, 7 October 2026)');
|
||||
});
|
||||
|
||||
test('the help line says what is pending, refused, held back by the switch, or skipped', () => {
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.1', source: 'ota', confirmed: false }).help, 'Waiting for this window to confirm the new interface.');
|
||||
assert.ok(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, error: 'the page did not answer within 10 s of loading it.' }).help.startsWith('The last interface over the air was refused:'));
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, builtin: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is published over the air; switch this off to take it.');
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, busy: true, published_version: '1.0.2' }).help, 'Interface 1.0.2 is downloading.');
|
||||
assert.equal(V.interfaceWords({ embedded_version: '1.0.0', active_version: '1.0.0', source: 'embedded', confirmed: true, note: 'interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19)' }).help, 'Interface 1.0.2 needs engine 0.3.20 or newer (this is 0.3.19).');
|
||||
});
|
||||
|
||||
test('the page reloads only when the served interface changed and nobody is mid-task', () => {
|
||||
const u = { active_version: '1.0.1' };
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard' }), true);
|
||||
assert.equal(V.shouldReload('1.0.1', u, { phase: 'dashboard' }), false, 'same version: nothing');
|
||||
assert.equal(V.shouldReload('', u, { phase: 'dashboard' }), false, 'a page that never pinged does not reload');
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', typing: true }), false);
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'dashboard', sheetOpen: true }), false);
|
||||
assert.equal(V.shouldReload('1.0.0', u, { phase: 'address' }), false, 'never mid-setup');
|
||||
assert.equal(V.shouldReload('1.0.0', null, { phase: 'dashboard' }), false);
|
||||
});
|
||||
|
||||
test('the page pings health once after its first paint and reports a first-paint error (the DOM block carries both)', () => {
|
||||
assert.ok(src.includes("api('api/ui/health', {})"), 'the health ping');
|
||||
assert.ok(src.includes("api('api/ui/health', { error: uiFirstError })"), 'the first-paint error');
|
||||
assert.ok(src.indexOf("window.addEventListener('error'") < src.indexOf('function poll()'), 'the error listener is installed before the first poll');
|
||||
assert.ok(!/<script[^>]+src=["']https?:/.test(readFileSync(join(here, 'index.html'), 'utf8')), 'no remote script in the page');
|
||||
});
|
||||
|
|
@ -14,10 +14,11 @@ const V = mod.exports.View;
|
|||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the four sections and their order (miner-ui-3)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
|
||||
test('the five sections and their order (miner-ui-4)', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['overview', 'cards', 'earnings', 'prove', 'settings']);
|
||||
assert.equal(V.page('cards').title, 'Cards');
|
||||
assert.equal(V.page('earnings').title, 'Earnings');
|
||||
assert.equal(V.page('nonsense').id, 'mine');
|
||||
assert.equal(V.page('nonsense').id, 'overview');
|
||||
});
|
||||
|
||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||
|
|
@ -50,6 +51,99 @@ test('a GPU row: temperatures turn amber then red; unknown numbers are empty', (
|
|||
assert.equal(apple.meta, '128 GB unified');
|
||||
});
|
||||
|
||||
test('an Intel Arc card is a discrete Intel row: INTEL badge, no power reading sentence, measure-only tune words (intel-arc, 7 October 2026)', () => {
|
||||
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', vram_mb: 12208, enabled: true, state: 'mining', hash_now: 15.2, power_w: 0, temp_gpu: 0, sweep_supported: false, sweep_state: 'unsupported', sweep_note: 'not available: Intel Arc exposes no power cap or reading through OpenCL (the driver\'s IGCL counters come next)' });
|
||||
const r = V.cardRow(arc);
|
||||
assert.equal(r.vendor, 'intel');
|
||||
assert.equal(r.kindWord, 'Discrete');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.on, true);
|
||||
assert.ok(r.meta.startsWith('12 GB'), r.meta);
|
||||
assert.equal(r.power, '', 'no watts cell without a reading');
|
||||
assert.equal(V.noReading(arc), 'No power or temperature reading on Intel Arc yet: the driver exposes them through IGCL, not OpenCL');
|
||||
assert.equal(V.tunable(arc), false, 'no cap, no clock control: not tunable');
|
||||
assert.equal(V.proveTier(arc), 'Intel(R) Arc(TM) B580 Graphics: cannot prove yet (proving needs an NVIDIA card).');
|
||||
const tm = mod.exports.TuneLine.model(arc, 1000);
|
||||
assert.equal(tm.kind, 'off');
|
||||
assert.equal(tm.text, 'tuning: measure only on Intel Arc once a power reading exists');
|
||||
});
|
||||
|
||||
test('driver-check: the row\'s driver strip per state (offer, running, reboot, error, note, fine)', () => {
|
||||
const offer = { vendor: 'intel', status: 'missing', installed: '', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034 (889 MB). No driver was found, so this card cannot mine yet.', installable: true };
|
||||
const arc = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'discrete', worker: 'OpenCL', driver_os: '', driver_offer: offer });
|
||||
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
|
||||
let w = V.driverWords(arc, idle);
|
||||
assert.equal(w.kind, 'offer');
|
||||
assert.equal(w.button, 'install');
|
||||
assert.equal(w.text, offer.text);
|
||||
assert.ok(w.sub.startsWith('Downloaded from downloadmirror.intel.com, checked against the vendor'), w.sub);
|
||||
// running: the progress line names the vendor, the version and the percent while downloading
|
||||
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'intel', version: '32.0.101.9034', progress: 0.42, message: 'downloading 32.0.101.9034 (889 MB) from downloadmirror.intel.com', dry_run: false } });
|
||||
assert.equal(w.kind, 'running');
|
||||
assert.equal(w.text, 'Downloading the Intel Arc driver 32.0.101.9034 · 42%');
|
||||
w = V.driverWords(arc, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: true } });
|
||||
assert.equal(w.text, 'Installing the Intel Arc driver 32.0.101.9034');
|
||||
assert.equal(w.dry, true);
|
||||
// reboot: the Restart now button, mining continues until the click
|
||||
w = V.driverWords(arc, { drivers: { status: 'reboot', vendor: 'intel', version: '32.0.101.9034', reboot_required: true } });
|
||||
assert.equal(w.kind, 'reboot');
|
||||
assert.equal(w.button, 'restart');
|
||||
assert.equal(w.text, 'Intel Arc driver 32.0.101.9034 installed. Restart Windows to finish.');
|
||||
// error: the reason and Install again
|
||||
w = V.driverWords(arc, { drivers: { status: 'error', vendor: 'intel', version: '32.0.101.9034', error: 'sha256 mismatch: the file is not the one the table names' } });
|
||||
assert.equal(w.kind, 'error');
|
||||
assert.equal(w.button, 'install');
|
||||
assert.ok(w.text.startsWith('The Intel Arc driver did not install: sha256 mismatch'), w.text);
|
||||
// another vendor\'s install does not touch this row
|
||||
w = V.driverWords(arc, { drivers: { status: 'downloading', vendor: 'nvidia', version: '617.42', progress: 0.1 } });
|
||||
assert.equal(w.kind, 'offer');
|
||||
// a note (macOS, Linux, HiveOS): no button
|
||||
const mac = card({ vendor: 'apple', kind: 'apple', driver_offer: { vendor: 'apple', status: 'none', text: 'Apple silicon: no driver step, macOS carries it.', installable: false } });
|
||||
w = V.driverWords(mac, idle);
|
||||
assert.equal(w.kind, 'note');
|
||||
assert.equal(w.button, undefined);
|
||||
const linux = card({ driver_offer: { vendor: 'nvidia', status: 'missing', text: 'Linux: install the package nvidia-driver-570 (the worker needs 570.00 or newer).', installable: false } });
|
||||
assert.equal(V.driverWords(linux, idle).kind, 'note');
|
||||
// fine: nothing on the row (the details panel carries the sentence)
|
||||
const fine = card({ driver_os: '617.42', driver_offer: { vendor: 'nvidia', status: 'fine', text: 'NVIDIA driver 617.42: fine.', installable: false } });
|
||||
assert.equal(V.driverWords(fine, idle).kind, 'fine');
|
||||
// no table: nothing
|
||||
assert.equal(V.driverWords(card({ driver_offer: null }), idle).kind, 'none');
|
||||
assert.equal(V.vendorWord('amd'), 'AMD');
|
||||
});
|
||||
|
||||
test('driver-check: an install offer says every card of the vendor stops, and an eGPU card adds the machine warning (PC 2, 7 October 2026; every-card rule 0.3.22)', () => {
|
||||
const offer = { vendor: 'intel', status: 'old', installed: '32.0.101.6733', wanted: '32.0.101.9034', min_version: '32.0.101.9034', size: 932631144, url: 'https://downloadmirror.intel.com/929959/gfx_win_101.9034.exe', hash_source: 'https://www.intel.com/content/www/us/en/download/785597/', text: 'Install the Intel Arc driver 32.0.101.9034, 889 MB (the worker needs 32.0.101.9034 or newer; 32.0.101.6733 is installed)', installable: true };
|
||||
const idle = { drivers: { status: 'idle', vendor: '', version: '', progress: 0, message: '', error: '', reboot_required: false, dry_run: false } };
|
||||
const egpu = card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', kind: 'external', worker: 'OpenCL', enabled: true, state: 'mining', driver_offer: offer });
|
||||
let w = V.driverWords(egpu, idle);
|
||||
assert.equal(w.kind, 'offer');
|
||||
assert.equal(w.external, true);
|
||||
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install and comes back after it; the other cards keep mining.'), w.sub);
|
||||
assert.ok(w.sub.includes('a card in an external enclosure can take the machine for a minute and may need a restart'), w.sub);
|
||||
// the same card read as inside the case (PC 2's Arc on 0.3.21 read discrete in its enclosure): the stop sentence stays,
|
||||
// the enclosure warning goes
|
||||
const inside = card({ ...egpu, kind: 'discrete' });
|
||||
w = V.driverWords(inside, idle);
|
||||
assert.equal(w.external, false);
|
||||
assert.ok(w.sub.includes('The worker on every Intel Arc card stops for the install'), w.sub);
|
||||
assert.ok(w.sub.endsWith('The display resets during the install: save your work first.'), w.sub);
|
||||
assert.ok(!w.sub.includes('Mining continues.'));
|
||||
// while it installs, the running line says the vendor's workers are stopped; the eGPU row adds its enclosure
|
||||
w = V.driverWords(egpu, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'installing INTEL 32.0.101.9034: Windows asks for permission once', dry_run: false } });
|
||||
assert.equal(w.kind, 'running');
|
||||
assert.ok(w.sub.includes('The Intel Arc workers are stopped until the installer ends; the display may reset, and this card\u2019s enclosure can take the machine'), w.sub);
|
||||
w = V.driverWords(inside, { drivers: { status: 'installing', vendor: 'intel', version: '32.0.101.9034', progress: 0.7, message: 'x', dry_run: false } });
|
||||
assert.ok(w.sub.endsWith('The Intel Arc workers are stopped until the installer ends; the display may reset.'), w.sub);
|
||||
});
|
||||
|
||||
test('a card behind a USB4 or Thunderbolt router is an eGPU on the Cards page (PC 2, 7 October 2026)', () => {
|
||||
const r = V.cardRow(card({ key: 'nvidia:1:NVIDIA GeForce RTX 5060 Ti', name: 'NVIDIA GeForce RTX 5060 Ti', vendor: 'nvidia', kind: 'external', enabled: true, state: 'mining', hash_now: 40, power_w: 120, temp_gpu: 55 }));
|
||||
assert.equal(r.kindWord, 'eGPU');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.canToggle, true);
|
||||
});
|
||||
|
||||
test('an integrated GPU is shown as integrated and off, with its reason', () => {
|
||||
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
|
||||
assert.equal(r.integrated, true);
|
||||
|
|
@ -61,7 +155,8 @@ test('an integrated GPU is shown as integrated and off, with its reason', () =>
|
|||
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
|
||||
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
|
||||
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
|
||||
// no fault is permanent (7 October 2026, docs/plans/miner-faults.md): a restarting card is the bad tone, there is no faulted state
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 120, message: 'hash rate 0 for 60 s while the node is synced; trying again' })).tone, 'bad');
|
||||
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
|
||||
});
|
||||
|
||||
|
|
@ -72,8 +167,8 @@ test('the big button: start when paused, stop when mining, disabled with no card
|
|||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||||
assert.equal(none.disabled, true);
|
||||
assert.equal(none.act, '');
|
||||
assert.equal(none.sub, 'switch a GPU on below first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
||||
assert.equal(none.sub, 'switch a card on in Cards first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no card this app can use');
|
||||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||||
});
|
||||
|
|
@ -106,11 +201,11 @@ test('the next consensus switch is the first height above the DAA score, in plai
|
|||
const next = V.nextSwitch(sw, 140000);
|
||||
assert.equal(next.name, 'Fees v1');
|
||||
assert.equal(next.away, 70000);
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at block 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||||
assert.equal(V.nextSwitch(sw, 300000), null);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned rule change is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A rule change is planned ahead/);
|
||||
assert.equal(V.nextSwitch([], 5), null);
|
||||
});
|
||||
|
||||
|
|
@ -139,9 +234,9 @@ test('the dev-fee lines name the share and the switch that turns it off', () =>
|
|||
|
||||
test('the remote-jobs line and the helpers', () => {
|
||||
const title = (j) => j.title || j.kind;
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until the switch above allows remote jobs.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off. Nothing runs here until the switch above allows jobs from the team.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'This build has no jobs address.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running: build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||||
|
|
@ -159,7 +254,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
|||
assert.equal(r.word, 'removed');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.match(r.sub, /^unplugged; its worker stopped/);
|
||||
assert.match(r.sub, /^unplugged\. Its row goes/);
|
||||
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
||||
const b = V.cardRow(bad);
|
||||
assert.equal(b.unusable, true);
|
||||
|
|
@ -194,7 +289,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
|||
assert.equal(V.present(bad), false);
|
||||
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
||||
assert.equal(t.disabled, true);
|
||||
assert.equal(t.sub, 'no GPU this app can drive');
|
||||
assert.equal(t.sub, 'no card this app can use');
|
||||
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
||||
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
||||
});
|
||||
|
|
@ -232,6 +327,7 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
|
|||
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
|
||||
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
|
||||
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
|
||||
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
|
||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
|
||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
|
||||
});
|
||||
|
|
@ -249,7 +345,7 @@ test('the tune line: never run, running with a step and a bar, tuned with the po
|
|||
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
|
||||
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
|
||||
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
|
||||
assert.match(prior.text, /^Tuned 2 min ago from the fleet prior, confirmed · 2470 MHz at 100%/);
|
||||
assert.match(prior.text, /^Tuned 2 min ago from what other miners found, confirmed · 2470 MHz at 100%/);
|
||||
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
|
||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
|
||||
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
|
||||
|
|
@ -279,7 +375,7 @@ test('the next check is a weekday inside a week, a date beyond it, due now when
|
|||
test('the Tuning card schedule and the goal consequence', () => {
|
||||
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
|
||||
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
|
||||
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work by hand.');
|
||||
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work when you press them.');
|
||||
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
|
||||
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
|
||||
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
|
||||
|
|
@ -295,8 +391,10 @@ test('the Tuning card schedule and the goal consequence', () => {
|
|||
test('the prove tier sentence per card, and the counts in one line', () => {
|
||||
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
|
||||
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
|
||||
// main's rule, 7 October 2026: under 12 GB the prover refuses and says why (the same sentence as src/provedefault.rs)
|
||||
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 3080', vram_mb: 10240 })), 'NVIDIA GeForce RTX 3080: proving needs a 12 GB card; mining continues (10 GB).');
|
||||
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
|
||||
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (the prover is CUDA only).');
|
||||
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (proving needs an NVIDIA card).');
|
||||
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
|
||||
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
|
||||
});
|
||||
|
|
@ -310,8 +408,650 @@ test('the node line, the activity line, the asks', () => {
|
|||
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
|
||||
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
|
||||
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
|
||||
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? The miner restarts.');
|
||||
assert.match(V.trustAsk(true), /^Include proof records the node never checked\? Devnet only; the node restarts\.$/);
|
||||
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
|
||||
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? Mining restarts.');
|
||||
assert.match(V.trustAsk(true), /^Include proofs the node never checked\? Devnet only\. The node restarts\.$/);
|
||||
assert.match(V.trustAsk(false), /^Check proofs again\?/);
|
||||
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
|
||||
});
|
||||
|
||||
test('the activity feed and the pill speak in the user\'s voice; the engine line is kept as the tooltip', () => {
|
||||
const pe = V.plainEvent('job update-now-0313-switch-d937c69d (Exec restart: re-read the manifest (exec_restart_number 27276) and restart the node with it) done after 0 min, exit 0, report uploaded: update check and install asked');
|
||||
assert.equal(pe.text, 'A job from the team ran: update check, 0 min');
|
||||
assert.match(pe.tech, /^job update-now/);
|
||||
assert.equal(V.plainEvent('updated to Igneum Miner 0.3.14 from 0.3.13').text, 'Updated to 0.3.14');
|
||||
assert.equal(V.plainEvent('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}').text, 'Rule settings read from the team');
|
||||
assert.equal(V.plainEvent('node synced: 151512 blocks, 6 peer(s)').text, 'Node synced: 151,512 blocks, 6 peers');
|
||||
assert.equal(V.plainEvent('block accepted by the node (Apple M5 Max)').text, 'Block found by Apple M5 Max');
|
||||
assert.equal(V.plainEvent('1 remote job received from Igneum').text, '1 job from the team received');
|
||||
assert.equal(V.plainEvent('remote job: update check now; a newer version installs at once').text, 'The team asked for an update check');
|
||||
assert.equal(V.plainEvent('a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)').text, 'Using the node already running on this machine');
|
||||
assert.equal(V.plainEvent('igneumd exited with code 101 after 300 s; restarting').text, 'The node stopped (code 101). Restarting');
|
||||
assert.equal(V.plainEvent('miner apple:0 exited with code 1; restarting in 8 s').text, 'A card stopped (code 1). Restarting in 8 s');
|
||||
assert.equal(V.plainEvent('NVIDIA GeForce RTX 5090 kernel race: l128w16 at 122.3 MH/s (+4.1% over base, 6 variants, 40 s)').text, 'NVIDIA GeForce RTX 5090: fastest program chosen, 122.3 MH/s');
|
||||
assert.equal(V.plainEvent('block 59199 shard 0 paid 0.0125 IGN').text, 'Proof paid: 0.0125 IGN');
|
||||
assert.equal(V.plainEvent('rewards go to 0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8').text, 'Rewards go to 0xdd44…86e8');
|
||||
assert.equal(V.plainEvent('mining resumed').text, 'Mining resumed');
|
||||
assert.equal(V.plainEvent('something new the engine says').text, 'Something new the engine says');
|
||||
assert.equal(V.plainEvent('something new the engine says').tech, '');
|
||||
const base = { setup_done: true, quitting: false, mining: { state: 'mining', hash_total: 510.6, cards: [] }, node: { state: 'synced' } };
|
||||
assert.deepEqual(V.pill(base), { text: 'Mining · 511 MH/s', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }), { text: 'Mining · 17.0 MH/s', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Paused', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'syncing' } }), { text: 'Syncing the node', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'restarting' } }), { text: 'Node restarting', tone: 'bad' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
|
||||
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
|
||||
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
|
||||
});
|
||||
|
||||
test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving, the sparkline points, the plain label', () => {
|
||||
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }];
|
||||
const tuned = card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 290, sweep_mhs: 122.3, tune_curve: curve, sweep_state: 'idle', sweep_note: '' });
|
||||
const e = V.ember(tuned, settings(), NOW);
|
||||
assert.equal(e.kind, 'tuned'); assert.equal(e.fill, 1);
|
||||
assert.equal(e.label, 'Tuned 2 h ago');
|
||||
assert.match(e.sub, /^2470 MHz at 80% · next check /);
|
||||
assert.equal(e.before, 374); assert.equal(e.after, 290); assert.equal(e.savingW, 84);
|
||||
assert.equal(e.savingText, 'saves 84 W, 0.16% of rate');
|
||||
assert.equal(e.points.length, 4); assert.equal(e.points.filter((p) => p.chosen).length, 1); assert.equal(e.points.filter((p) => p.marked).length, 1);
|
||||
// Ember's own fields win over the curve (tune_before_watts / tune_before_mhs, step 0 of the last full plan)
|
||||
const withFields = V.ember(card({ ...tuned, tune_before_watts: 380, tune_before_mhs: 122.4, tune_source: 'confirm' }), settings(), NOW);
|
||||
assert.equal(withFields.before, 380); assert.equal(withFields.savingW, 90); assert.match(withFields.label, /from what other miners found$/);
|
||||
// a confirm plan without the fields claims no saving (its curve's first row is the prior, not an untuned point)
|
||||
assert.equal(V.ember(card({ ...tuned, tune_source: 'confirm' }), settings(), NOW).savingText, '');
|
||||
assert.equal(V.ember(card({ ...tuned, tune_before_watts: 0 }), settings(), NOW).savingText, '', 'zero means never measured');
|
||||
const running = V.ember(card({ state: 'tuning', tune_step: 4, tune_steps: 9, tune_eta_s: 410, sweep_state: 'running' }), settings(), NOW);
|
||||
assert.equal(running.kind, 'running'); assert.equal(running.fill.toFixed(2), '0.33'); assert.equal(running.label, 'Tuning: step 4 of 9'); assert.equal(running.sub, 'about 7 min left');
|
||||
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
|
||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
|
||||
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
|
||||
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
|
||||
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
|
||||
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
|
||||
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
|
||||
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
|
||||
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
|
||||
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
|
||||
});
|
||||
|
||||
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
|
||||
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
|
||||
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
|
||||
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
|
||||
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
|
||||
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
|
||||
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
|
||||
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
|
||||
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
|
||||
const cards = [card(), card({ key: 'b', power_w: 100 })];
|
||||
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
|
||||
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
|
||||
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
|
||||
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
|
||||
assert.equal(V.fleetPounds({}, cards, 0), null);
|
||||
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
|
||||
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
|
||||
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
|
||||
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
|
||||
assert.equal(V.liveNote(null), '');
|
||||
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
|
||||
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
|
||||
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
|
||||
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
|
||||
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
|
||||
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
|
||||
assert.equal(V.isMine('deadbeef', mineCards), false);
|
||||
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
|
||||
assert.equal(V.isMine('', mineCards), false);
|
||||
});
|
||||
|
||||
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
|
||||
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
|
||||
const ok = V.nodeLine(n(), { severity: 'none' }, '');
|
||||
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
|
||||
assert.match(ok.sub, /Last block 3 s ago/);
|
||||
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
|
||||
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
|
||||
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
|
||||
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
|
||||
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
|
||||
assert.match(none.sub, /^No other node on our chain is connected/);
|
||||
// an older node without the age keeps the old words
|
||||
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
|
||||
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
|
||||
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
|
||||
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
|
||||
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
|
||||
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
|
||||
});
|
||||
|
||||
test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => {
|
||||
const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, '');
|
||||
assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/);
|
||||
});
|
||||
|
||||
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
|
||||
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
|
||||
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
|
||||
assert.equal(/\bfinal\b/i.test(p.note), false);
|
||||
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
|
||||
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
|
||||
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
|
||||
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
|
||||
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
|
||||
// N5: a node built without the mining engine is a fault
|
||||
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
|
||||
});
|
||||
|
||||
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
|
||||
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (an older node)' });
|
||||
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
|
||||
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
|
||||
// 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why
|
||||
assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' });
|
||||
const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||
assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/);
|
||||
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
|
||||
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
|
||||
});
|
||||
|
||||
// ---- miner-ui-5 (7 October 2026): the miner's first month ----
|
||||
// The chain facts as GET api/ladder shapes them (src/chainfacts.rs) for one key, 6ad0e117 (the example key of the project lead's
|
||||
// chain-scene reference), under the finality params the devnet node reports (api/live params, 7 Oct 2026: dust 5,
|
||||
// weightWindow 7,200 s, presenceWindow 20, checkpointInterval 30; tools/fleet/devnet2-override.json sets none of them,
|
||||
// so Devnet 2 runs the same values). Every number the rungs show is a field of getFinalityWeights,
|
||||
// getFinalityCheckpoints, /api/stats or this app's own ladder.json, and the rung names it on hover.
|
||||
const DN2 = { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 };
|
||||
const MAINNET = { dust: 100, weight_window: 2592000, presence_window: 240, checkpoint_interval: 30, min_daa: 7200 };
|
||||
const chainOf = (over, params) => ({ ok: true, source: 'node', rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: '' }, params: params || DN2, checkpoint_index: 8495, latest_locked_index: 8495, next_index: 8496, finality_active: true, total_weight: 6000, active_weight: 5900, voters: 14, keys_listed: 4, keys_cap: 0, network: { hashrate_hps: 100e9, miner_ign_per_block: 80, ign_per_block: 100, ramp_factor: 1, daa: 1284117, blocks_per_day_measured: 86400, bps: 1, stale: false }, mine: [], best: null, ...over });
|
||||
const key = (over) => ({ id: '6ad0e117', blocks: 0, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4, ...over });
|
||||
const T5 = 1791362116;
|
||||
const stateOf = (over) => ({ now: T5, uptime_s: 1800, version: '0.3.19', installed_at: T5 - 3000, setup_done: true, address: { display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8' }, settings: { prove: false, dev_fee: true, power_price_pence: 28 }, node: { synced: true }, proving: { paid: 0, paid_wei: 0 }, mining: { state: 'mining', hash_total: 100, accepted_total: 0, accepted_session: 0, found: [], cards: [card({ key: 'nvidia:0:RTX 4070', name: 'NVIDIA GeForce RTX 4070', hash_now: 100, hash_avg: 100, power_w: 150, ids: ['6ad0e117'] })] }, ladder: { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: 0, first_mining_at: 0 }, ...over });
|
||||
const RPC = /getFinalityWeights|getFinalityCheckpoints|igneum_getProofRecords|\/api\/stats|this app/;
|
||||
|
||||
test('the Poisson count-up: the gap from the card’s rate and the network’s, the chance from the time waited', () => {
|
||||
const a = V.poisson(100, 100e9, 0, 1);
|
||||
assert.equal(Math.round(a.gap_s), 1000);
|
||||
assert.equal(a.gapText, '17 minutes');
|
||||
assert.equal(a.chanceText, '0%');
|
||||
assert.equal(a.line, 'A card like yours finds a block about every 17 minutes on today’s network. Chance so far: 0%.');
|
||||
assert.equal(a.pool, '');
|
||||
const b = V.poisson(17, 100e9, 1800, 1);
|
||||
assert.equal(b.gapText, '1.6 hours');
|
||||
assert.equal(b.chanceText, '26%');
|
||||
const c = V.poisson(17, 1e12, 3600, 1);
|
||||
assert.equal(c.gapText, '16 hours');
|
||||
assert.match(c.pool, /^Above an 8-hour gap a pool pays by the share/);
|
||||
assert.equal(V.poisson(100, 100e9, 1000, 1).chanceText, '63%');
|
||||
assert.equal(V.poisson(17, 10e12, 0, 1).gapText, '6.8 days');
|
||||
assert.equal(V.poisson(100, 100e9, 10, 1, true).line.indexOf('Your cards find'), 0);
|
||||
assert.equal(V.poisson(0, 100e9, 10, 1), null);
|
||||
assert.equal(V.poisson(100, 0, 10, 1), null);
|
||||
// IGN a day: 86,400 blocks a day, the share of them, 80 producer points each (the litepaper's 6,912 example)
|
||||
assert.equal(Math.round(V.ignPerDay(100, 100e9, 80, 1)), 6912);
|
||||
assert.equal(V.ignPerDay(100, 0, 80, 1), null);
|
||||
});
|
||||
|
||||
test('a Devnet 2 key walks every rung on screen, and every rung names its RPC field (the gate)', () => {
|
||||
// 0. mining, nothing found: the first rung is "now", the rest wait; the count-up line is on
|
||||
let s = stateOf(), c = chainOf();
|
||||
let r = V.ladderRungs(s, c, T5);
|
||||
assert.deepEqual(r.map((x) => x.id), ['first', 'vote', 'signed', 'window', 'rank', 'streak', 'shard']);
|
||||
assert.deepEqual(r.map((x) => x.state), ['now', 'next', 'next', 'next', 'next', 'next', 'off']);
|
||||
assert.equal(r[0].line, 'Waiting for your first block.');
|
||||
assert.equal(V.currentRung(r).id, 'first');
|
||||
r.forEach((x) => assert.match(x.field, RPC, x.id + ' names no RPC field: ' + x.field));
|
||||
assert.equal(V.blockCard(null, s, c, T5), null);
|
||||
// 1. the first block: the card raises, the key enters the table with one block under the dust line
|
||||
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
|
||||
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, milestone: m1 } });
|
||||
c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.deepEqual(r.map((x) => x.state), ['done', 'now', 'next', 'now', 'done', 'next', 'off']);
|
||||
assert.equal(r[0].line, 'First block 1 min ago, NVIDIA GeForce RTX 4070.');
|
||||
assert.equal(r[0].sub, '1 block with your key in the window');
|
||||
assert.equal(r[1].line, '1 of 5 blocks to a vote.');
|
||||
assert.equal(r[1].progress, 0.2);
|
||||
assert.equal(r[3].line, '1 of 2 hours.');
|
||||
assert.equal(r[4].line, 'Rank 4 of 4 keys.');
|
||||
assert.equal(V.currentRung(r).id, 'vote');
|
||||
const bc = V.blockCard(m1, s, c, T5);
|
||||
assert.equal(bc.eyebrow, 'your first block');
|
||||
assert.equal(bc.title, 'Block 1,284,117 is yours.');
|
||||
assert.equal(bc.cardLine, 'NVIDIA GeForce RTX 4070 · 100 MH/s · 0.100% of the network');
|
||||
assert.equal(bc.ignLine, '80.00 IGN to 0xdd44…86E8 (72 producer points, 8 for signing)');
|
||||
assert.equal(bc.rankLine, 'rank 4 of 4 keys · hour 1 of 2');
|
||||
assert.equal(bc.url, 'https://igneum.network/block/' + m1.hash);
|
||||
assert.equal(bc.hashShort, '9f3aabab…abc21e');
|
||||
assert.match(bc.fields.ign, /block_reward\.miner_ign/);
|
||||
// 2. five blocks: the vote
|
||||
c = chainOf({ mine: [key({ blocks: 5, voter: true, revealed: true, rank: 4 })], best: key({ blocks: 5, voter: true, revealed: true, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[1].state, 'done');
|
||||
assert.equal(r[1].line, 'Your key has a vote.');
|
||||
assert.equal(r[1].sub, '5 blocks in the window, the line is 5');
|
||||
assert.equal(r[2].state, 'now');
|
||||
assert.equal(r[2].line, 'Signing: the next locked checkpoint carries your vote.');
|
||||
assert.equal(r[5].state, 'now');
|
||||
// 3. the signature in a locked checkpoint, the streak
|
||||
s = stateOf({ ...s, ladder: { ...s.ladder, votes_signed: 31, last_vote_index: 8496, last_vote_at: T5 - 5, last_signed_locked: 8496, signed_locked_count: 30, streak_s: 900 } });
|
||||
c = chainOf({ latest_locked_index: 8496, mine: [key({ blocks: 5, voter: true, participation: 1, rank: 4 })], best: key({ blocks: 5, voter: true, participation: 1, rank: 4 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[2].state, 'done');
|
||||
assert.equal(r[2].line, 'Your signature is in checkpoint 8,496.');
|
||||
assert.equal(r[2].sub, '30 signed and locked · presence 100% · latest lock 8,496');
|
||||
assert.equal(r[5].state, 'done');
|
||||
assert.equal(r[5].line, 'Signing 8 of 80 points, 15 min unbroken.');
|
||||
// 4. the full window: two hours on the devnet; the rank
|
||||
s = stateOf({ ...s, ladder: { ...s.ladder, first_block_at: T5 - 7300, blocks_30d: 61, blocks_today: 61 } });
|
||||
c = chainOf({ latest_locked_index: 8700, mine: [key({ blocks: 61, voter: true, participation: 1, rank: 3 })], best: key({ blocks: 61, voter: true, participation: 1, rank: 3 }) });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[3].state, 'done');
|
||||
assert.equal(r[3].line, '2 of 2 hours. Full weight.');
|
||||
assert.equal(r[4].line, 'Rank 3 of 4 keys.');
|
||||
assert.equal(r[4].sub, '14 keys vote · weight, never hashrate: a card that arrived today sits at the bottom');
|
||||
// P. a paid shard
|
||||
s = stateOf({ ...s, settings: { ...s.settings, prove: true }, proving: { paid: 1, paid_wei: '1150000000000000000' }, ladder: { ...s.ladder, shards: [{ block: 1284117, shard: 3, wei: '1150000000000000000', at: T5 - 30 }] } });
|
||||
r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[6].state, 'done');
|
||||
assert.equal(r[6].line, 'Your card proved shard 3 of block 1,284,117.');
|
||||
assert.equal(r[6].sub, '1.15 IGN · 1 paid');
|
||||
assert.deepEqual(r.map((x) => x.state), ['done', 'done', 'done', 'done', 'done', 'done', 'done']);
|
||||
r.forEach((x) => assert.match(x.field, RPC));
|
||||
assert.equal(V.currentRung(r).id, 'shard');
|
||||
// proving off: the rung reads off, never a fault
|
||||
assert.equal(V.ladderRungs(stateOf(), chainOf(), T5)[6].line, 'Proving is off.');
|
||||
});
|
||||
|
||||
test('first-block-21 (the project lead, 7 October 2026): the first-block card is seen once in the app’s life, never on a later run', () => {
|
||||
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
|
||||
const c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
|
||||
const afterFirst = (over, ladderOver) => stateOf({ mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 7200, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: true, ...ladderOver }, ...over });
|
||||
const stale = { ...m1, at: T5 - 7200 };
|
||||
// KNOWN-FAILED FIRST: a second run against a 0.3.20 engine (its ladder has no first_block_shown field; the engine up
|
||||
// 30 s, the lifetime count already 1, the card raised two hours ago on the first run and re-sent from its record)
|
||||
// showed "your first block" again before this change
|
||||
let s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { milestone: stale });
|
||||
delete s.ladder.first_block_shown;
|
||||
assert.equal(V.blockCard(stale, s, c, T5), null, 'a second run with the count at 1 shows no first-block card');
|
||||
assert.equal(V.staleCard(stale, s, T5), true);
|
||||
// the first ever block: the card, in the run that raised it, and the body the page reports when it displays it
|
||||
s = stateOf({ started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, first_block_shown: false, milestone: m1 } });
|
||||
const bc = V.blockCard(m1, s, c, T5);
|
||||
assert.equal(bc.eyebrow, 'your first block');
|
||||
assert.equal(bc.title, 'Block 1,284,117 is yours.');
|
||||
assert.deepEqual(V.cardSeenBody(bc), { count: 1, at: T5 - 60 });
|
||||
assert.equal(V.cardSeenBody(null), null);
|
||||
assert.equal(V.firstWait(s), false, 'the count-up gives way to the card');
|
||||
// seen once (main's reading): a block found overnight with no window, the engine restarted since (an auto-update):
|
||||
// a 0.3.21 engine keeps the unseen card and the first open shows it, however old it is
|
||||
s = afterFirst({ uptime_s: 30, started_at: T5 - 30 }, { first_block_shown: false, milestone: stale });
|
||||
assert.equal(V.staleCard(stale, s, T5), false, 'a 0.3.21 engine owns the rule');
|
||||
assert.equal(V.blockCard(stale, s, c, T5).eyebrow, 'your first block');
|
||||
// a second open after it was seen: the engine dropped the card at its start (ladder.rs start_run); the first rung
|
||||
// stays done and the count-up never comes back
|
||||
s = afterFirst({ uptime_s: 12, started_at: T5 - 12 }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
|
||||
assert.equal(V.firstWait(s), false);
|
||||
// a restart with the count at 1 and the card seen: nothing to show
|
||||
s = afterFirst({ uptime_s: 45, started_at: T5 - 45 }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
// an update with the count at 1 from 0.3.20: the engine takes the flag from the old record and sends no card; a
|
||||
// 0.3.20 engine without started_at re-sending its card is refused through uptime_s
|
||||
s = afterFirst({ uptime_s: 45, started_at: T5 - 45, version: '0.3.21' }, { milestone: null });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
s = afterFirst({ uptime_s: 45, started_at: 0 }, { milestone: stale });
|
||||
delete s.ladder.first_block_shown;
|
||||
assert.equal(V.blockCard(stale, s, c, T5), null);
|
||||
// a kept data directory with the count at 0 and the flag unset: the count-up shows, then the first block raises the card
|
||||
s = stateOf({ uptime_s: 300, started_at: T5 - 300, ladder: { ...stateOf().ladder, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40 } });
|
||||
assert.equal(V.firstWait(s), true);
|
||||
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].line, 'Waiting for your first block.');
|
||||
s = stateOf({ uptime_s: 400, started_at: T5 - 400, mining: { ...stateOf().mining, accepted_total: 1, accepted_session: 1, found: [T5 - 5] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 5, first_block_shown: false, first_synced_at: T5 - 86400 * 3, first_mining_at: T5 - 86400 * 3, votes_signed: 40, milestone: { ...m1, at: T5 - 5 } } });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5).eyebrow, 'your first block');
|
||||
assert.equal(V.firstWait(s), false);
|
||||
// a count that started over (settings.json lost, the ladder kept with the flag): no "waiting", no card
|
||||
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 0, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 86400, first_block_shown: true, milestone: null } });
|
||||
assert.equal(V.firstWait(s), false);
|
||||
assert.equal(V.ladderRungs(s, chainOf(), T5)[0].state, 'done');
|
||||
assert.equal(V.blockCard(null, s, c, T5), null);
|
||||
// block 2: the ordinary accepted pulse only (the block on the strip's list, the activity line, the run count), no card
|
||||
s = afterFirst({ uptime_s: 1800, started_at: T5 - 1800, mining: { ...stateOf().mining, accepted_total: 2, accepted_session: 1, found: [T5 - 2] } }, { milestone: null, blocks_30d: 2, blocks_today: 2 });
|
||||
assert.equal(V.blockCard(s.ladder.milestone, s, c, T5), null);
|
||||
assert.equal(V.plainEvent('block accepted by the node (NVIDIA GeForce RTX 4070)').text, 'Block found by NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.earningsLines(s, c, 28, T5).run.text, '1 block this run');
|
||||
assert.equal(V.earningsLines(s, c, 28, T5).lifetime.text, '2 blocks');
|
||||
assert.deepEqual(s.mining.found, [T5 - 2], 'the strip flashes the block that just arrived');
|
||||
assert.equal(V.ladderRungs(s, c, T5)[0].state, 'done');
|
||||
});
|
||||
|
||||
test('the rungs under mainnet-sized params: 100 blocks to a vote, 30 days to full weight; the relay and the cap are named', () => {
|
||||
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 61, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 61 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64, voters: 1204, mine: [key({ blocks: 61, rank: 41 })], best: key({ blocks: 61, rank: 41 }) }, MAINNET);
|
||||
const r = V.ladderRungs(s, c, T5);
|
||||
assert.equal(r[1].line, '61 of 100 blocks to a vote.');
|
||||
assert.equal(r[3].line, '23 of 30 days.');
|
||||
assert.equal(r[3].progress, 23 / 30);
|
||||
assert.equal(r[4].line, 'Rank 41 of 64 keys.');
|
||||
assert.equal(r[4].sub, '1,204 keys vote · the top 64 are listed · weight, never hashrate: a card that arrived today sits at the bottom');
|
||||
// a key below the observer's 64 says so instead of inventing a rank
|
||||
const below = V.ladderRungs(s, chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64 }, MAINNET), T5);
|
||||
assert.equal(below[4].line, 'Below the top 64 keys.');
|
||||
assert.equal(below[1].sub, 'your key is not in the table yet');
|
||||
// no chain facts at all: the words say so, nothing is invented
|
||||
const none = V.ladderRungs(s, { ok: false, error: 'neither the node nor the observer answered' }, T5);
|
||||
assert.equal(none[1].line, 'A vote needs the dust line’s blocks in the window.');
|
||||
assert.equal(none[1].sub, 'neither the node nor the observer answered');
|
||||
assert.equal(none[4].line, 'Rank: not read yet.');
|
||||
assert.deepEqual(V.windowWords(2592000), { n: 30, unit: 'days', text: '30 days' });
|
||||
assert.deepEqual(V.windowWords(7200), { n: 2, unit: 'hours', text: '2 hours' });
|
||||
assert.deepEqual(V.windowWords(0), { n: 30, unit: 'days', text: '30 days' });
|
||||
});
|
||||
|
||||
test('the per-card IGN a day cell and the IGN formatter', () => {
|
||||
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), chainOf()), '1,728');
|
||||
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), null), '');
|
||||
assert.equal(V.fmtIgn(0.5), '0.5000 IGN'); assert.equal(V.fmtIgn(4.88044084), '4.88 IGN'); assert.equal(V.fmtIgn(207360), '207,360 IGN');
|
||||
});
|
||||
test('the block card on the four thresholds and on a new card; the first-hour timeline; the profile words', () => {
|
||||
const s = stateOf(), c = chainOf({ mine: [key({ blocks: 100, voter: true, rank: 2 })], best: key({ blocks: 100, voter: true, rank: 2 }) });
|
||||
const m = (kind, count, over) => ({ kind, count, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: 'ab'.repeat(32), daa: 1284117, at: T5 - 10, ...over });
|
||||
assert.equal(V.blockCard(m('hundred', 100), s, c, T5).eyebrow, 'block 100 of this machine');
|
||||
assert.equal(V.blockCard(m('thousand', 1000), s, c, T5).eyebrow, 'block 1,000 of this machine');
|
||||
assert.equal(V.blockCard(m('ten_thousand', 20000), s, c, T5).eyebrow, 'block 20,000 of this machine');
|
||||
assert.equal(V.blockCard(m('card', 7), s, c, T5).eyebrow, 'first block on NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).title, 'A block is yours.');
|
||||
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).url, '');
|
||||
assert.equal(V.blockCard(m('first', 1), s, chainOf({ network: { ...chainOf().network, ramp_factor: 0.19, miner_ign_per_block: 4.88044084 } }), T5).ignLine, '4.88 IGN to 0xdd44…86E8 (72 producer points, 8 for signing, ramp 19%)');
|
||||
assert.equal(V.blockCard(m('first', 1), s, c, T5).key, 'card:1:' + (T5 - 10));
|
||||
const t = V.timeline(stateOf({ ladder: { ...stateOf().ladder, first_synced_at: T5 - 2800, first_mining_at: T5 - 2700, first_block_at: T5 - 2400 } }), T5);
|
||||
assert.deepEqual(t.steps.map((x) => x.id), ['install', 'started', 'synced', 'mining', 'block', 'payout']);
|
||||
assert.deepEqual(t.steps.map((x) => x.rel), ['+0 s', '+20 min', '+3 min', '+5 min', '+10 min', '+10 min']);
|
||||
assert.match(t.steps[5].note, /coinbase of the first block/);
|
||||
const u = V.timeline(stateOf({ installed_at: 0, uptime_s: 100 }), T5);
|
||||
assert.equal(u.steps[0].note, 'not recorded on this install');
|
||||
assert.equal(u.steps[2].note, 'the moment is not recorded; synced now');
|
||||
assert.match(V.profileWords(true), /^Your address page on igneum\.network is public/);
|
||||
assert.match(V.profileWords(false), /unlisted/);
|
||||
assert.equal(V.gapWords(45), '45 seconds'); assert.equal(V.spanWords(3700), '1 h 1 min'); assert.equal(V.spanWords(3 * 86400), '3 days');
|
||||
// the Activity lines for the milestones
|
||||
assert.equal(V.plainEvent('first block: found by NVIDIA GeForce RTX 4070 (block card)').text, 'Your first block, found by NVIDIA GeForce RTX 4070');
|
||||
assert.equal(V.plainEvent('block 1,000 of this machine: found by RTX 4070 (block card)').text, 'Block 1,000 of this machine, found by RTX 4070');
|
||||
assert.equal(V.plainEvent('first block on Apple M5 Max (block card)').text, 'First block on Apple M5 Max');
|
||||
});
|
||||
|
||||
// ---------- vendor marks (gpu-logos, 7 October 2026) ----------
|
||||
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.css'), 'utf8');
|
||||
const marks = (html) => (html.match(/data-mark="/g) || []).length;
|
||||
|
||||
test('every vendor renders its own mark: one inline SVG glyph per row, tinted by its vendor class, under 2 KB each', () => {
|
||||
const rows = {
|
||||
nvidia: card(),
|
||||
amd: card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', vram_mb: 16384 }),
|
||||
intel: card({ key: 'intel:Intel(R) Arc(TM) B580 Graphics', name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel', worker: 'OpenCL', vram_mb: 12208 }),
|
||||
apple: card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072 }),
|
||||
};
|
||||
for (const [v, cd] of Object.entries(rows)) {
|
||||
const mk = V.vendorMark(cd);
|
||||
assert.equal(mk.vendor, v);
|
||||
assert.match(mk.svg, /^<svg viewBox="0 0 24 24"/);
|
||||
assert.ok(Buffer.byteLength(mk.svg) < 2048, v + ' glyph is ' + Buffer.byteLength(mk.svg) + ' bytes');
|
||||
assert.ok(!/<image|href=|data:/.test(mk.svg), v + ' glyph is drawn, never a raster or a file');
|
||||
assert.ok(/currentColor/.test(mk.svg), v + ' glyph takes the vendor token through currentColor');
|
||||
const html = V.markHtml(cd);
|
||||
assert.ok(html.startsWith('<div class="badge ' + v + '" data-mark="' + v + '"'), html.slice(0, 60));
|
||||
assert.equal((html.match(/<svg/g) || []).length, 1);
|
||||
}
|
||||
assert.notEqual(V.MARKS.nvidia, V.MARKS.amd); assert.notEqual(V.MARKS.intel, V.MARKS.apple);
|
||||
});
|
||||
|
||||
test('an unknown vendor renders the neutral fallback glyph; a vendor "other" row whose name says Intel keeps the Intel glyph', () => {
|
||||
const mali = card({ key: 'other:0:Mali', name: 'Mali-G78 MP20', vendor: 'other', kind: 'integrated', worker: 'OpenCL', vram_mb: 0 });
|
||||
const mk = V.vendorMark(mali);
|
||||
assert.equal(mk.vendor, 'gpu'); assert.equal(mk.label, 'GPU'); assert.equal(mk.svg, V.MARKS.gpu);
|
||||
assert.ok(V.markHtml(mali).startsWith('<div class="badge gpu" data-mark="gpu"'));
|
||||
assert.equal(V.vendorOf(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'intel');
|
||||
assert.equal(V.vendorOf(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'other', kind: 'integrated' })), 'amd');
|
||||
assert.equal(V.vendorOf({}), 'gpu');
|
||||
});
|
||||
|
||||
test('the mark never appears twice on one row: the head of a first-run row and of a Cards row carries one data-mark, and only View draws a badge', () => {
|
||||
const amd = card({ key: 'amd:gfx1201', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd' });
|
||||
assert.equal(marks(V.rowHead(amd)), 1);
|
||||
assert.equal(marks(V.rowHead(amd, ': removed')), 1);
|
||||
assert.equal(marks(V.markHtml(amd) + V.nameHtml(amd)), 1);
|
||||
assert.equal(marks(V.nameHtml(amd)), 0, 'the name block carries no mark of its own');
|
||||
// the renderers: the first-run rows, the Cards rows and the block card all go through View.markHtml; no other
|
||||
// string in app.js opens a badge
|
||||
assert.equal((src.match(/class="badge /g) || []).length, 1, 'one place in app.js opens a badge');
|
||||
assert.equal((src.match(/View\.markHtml\(/g) || []).length, 3, 'first-run rows, Cards rows, the block card');
|
||||
assert.equal(src.includes('badgeHtml('), false);
|
||||
});
|
||||
|
||||
test('the series line under the name is mono text per generation; an integrated row (Intel iGPU, Apple) ends in "integrated" and keeps one mark', () => {
|
||||
assert.equal(V.seriesLine(card()), 'RTX 50 series · Blackwell');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 4070' })), 'RTX 40 series · Ada Lovelace');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce RTX 3080' })), 'RTX 30 series · Ampere');
|
||||
assert.equal(V.seriesLine(card({ name: 'NVIDIA GeForce GTX 1660 Super' })), 'GTX 16 series · Turing');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd' })), 'RX 9000 series · RDNA 4');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon RX 7800 XT', vendor: 'amd' })), 'RX 7000 series · RDNA 3');
|
||||
assert.equal(V.seriesLine(card({ name: 'gfx1201', code: 'gfx1201', vendor: 'amd' })), 'RDNA 4');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel(R) Arc(TM) B580 Graphics', vendor: 'intel' })), 'Arc B series · Battlemage');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel Arc A770', vendor: 'intel' })), 'Arc A series · Alchemist');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' })), 'UHD Graphics · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'Intel Iris Xe Graphics', vendor: 'other', kind: 'integrated' })), 'Iris Xe · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'AMD Radeon(TM) Graphics', vendor: 'amd', kind: 'integrated' })), 'Radeon · integrated');
|
||||
assert.equal(V.seriesLine(card({ name: 'Apple M5 Max', vendor: 'apple', kind: 'apple' })), 'M5 series · integrated');
|
||||
const igpu = card({ name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated' });
|
||||
const head = V.rowHead(igpu);
|
||||
assert.equal(marks(head), 1); assert.match(head, /data-mark="intel"/); assert.match(head, /<div class="gen">UHD Graphics · integrated<\/div>/);
|
||||
assert.equal(V.vendorMark(igpu).svg, V.MARKS.intel, 'an integrated Intel row keeps the Intel glyph, never a second mark');
|
||||
assert.equal(V.nameHtml(card({ name: '<b>x</b>', vendor: 'zzz', kind: 'unknown' })).includes('<b>'), false, 'the name is escaped');
|
||||
});
|
||||
|
||||
test('the light theme contrast ratio of every vendor colour on its well is at least 3:1, and app.css carries the same hex values and wells', () => {
|
||||
for (const v of Object.keys(V.VENDORS)) {
|
||||
const light = V.vendorContrast(v, 'light'), dark = V.vendorContrast(v, 'dark');
|
||||
assert.ok(light >= 3, v + ' light: ' + light.toFixed(2) + ':1 on its well');
|
||||
assert.ok(dark >= 3, v + ' dark: ' + dark.toFixed(2) + ':1 on its well');
|
||||
const [r, g, b] = V.hexRgb(V.VENDORS[v].light), [dr, dg, db] = V.hexRgb(V.VENDORS[v].dark);
|
||||
assert.ok(css.includes('--mark-' + v + ':' + V.VENDORS[v].dark + ';'), v + ' dark hex in app.css');
|
||||
assert.equal((css.match(new RegExp('--mark-' + v + ':' + V.VENDORS[v].light + ';', 'g')) || []).length, 2, v + ' light hex in both light blocks');
|
||||
assert.ok(css.includes('--mark-' + v + '-well:rgba(' + dr + ',' + dg + ',' + db + ',' + String(V.WELL_ALPHA_DARK).replace(/^0/, '') + ')'), v + ' dark well');
|
||||
assert.equal((css.match(new RegExp('--mark-' + v + '-well:rgba\\(' + r + ',' + g + ',' + b + ',' + String(V.WELL_ALPHA_LIGHT).replace(/^0/, '') + '\\)', 'g')) || []).length, 2, v + ' light well in both light blocks');
|
||||
}
|
||||
// the ember accent is for state, never a brand
|
||||
for (const v of Object.keys(V.VENDORS)) { assert.notEqual(V.VENDORS[v].dark, '#F2541B'); assert.notEqual(V.VENDORS[v].light, '#E04A14'); }
|
||||
assert.equal(/\.badge\.(nvidia|amd|intel|apple|gpu)\{[^}]*var\(--ember/.test(css), false, 'no badge rule tints with ember');
|
||||
assert.equal(/\.badge[^{]*\{[^}]*animation/.test(css), false, 'nothing on the mark animates');
|
||||
});
|
||||
|
||||
// ---------- the custom switch (the Prove page fix, 7 October 2026) ----------
|
||||
// the project lead's screenshot from the live 0.3.19 Mac app: a thin white rectangle above-left of the "Prove on this machine"
|
||||
// switch and the knob drawn outside the track's left edge at off. Two generic classes reached the switch (the DAG
|
||||
// legend swatch .lg hit label.switch.lg, the shard track .track hit .switch .track) and the native input was an
|
||||
// unpositioned absolute. This test reads app.css: no bare .lg or .track rule may exist (every use is scoped), the
|
||||
// native input is hidden the accessible way (1 px clip, still focusable), and the knob's box sits inside the track's
|
||||
// box at off and at on for both sizes, in the base rules and untouched by both theme blocks.
|
||||
const cssRules = (text) => { const out = []; const re = /([^{}]+)\{([^{}]*)\}/g; let m; text = text.replace(/\/\*[\s\S]*?\*\//g, ''); while ((m = re.exec(text))) out.push({ sel: m[1].trim(), body: m[2] }); return out; };
|
||||
const ruleOf = (sel) => cssRules(css).filter((r) => r.sel.split(',').map((x) => x.trim()).includes(sel)).map((r) => r.body).join(';');
|
||||
const px = (body, prop) => { const m = new RegExp('(?:^|;)\\s*' + prop + ':\\s*(-?[\\d.]+)px').exec(body); return m ? parseFloat(m[1]) : null; };
|
||||
const translate = (body) => { const m = /transform:\s*translateX\((-?[\d.]+)px\)/.exec(body); return m ? parseFloat(m[1]) : 0; };
|
||||
|
||||
test('no generic .lg or .track rule reaches a custom switch: every use of those class names is scoped (known-failed on the 0.3.19 CSS)', () => {
|
||||
const bare = cssRules(css).flatMap((r) => r.sel.split(',').map((x) => x.trim())).filter((sel) => /^\.(lg|track)(\.[\w-]+)*(\s+[\w.:-]+)*$/.test(sel) && !/^\.switch\b/.test(sel));
|
||||
assert.deepEqual(bare, [], 'selectors that start with a bare .lg or .track: ' + bare.join(' | '));
|
||||
assert.equal(/<label class="switch lg"/.test(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8')), false, 'the Prove switch no longer shares the legend swatch class');
|
||||
});
|
||||
|
||||
test('the native input of every custom switch is hidden the accessible way: 1 px clipped, still in the tree and focusable, the label kept', () => {
|
||||
const sw = ruleOf('.switch'), input = ruleOf('.switch input');
|
||||
assert.match(sw, /position:\s*relative/, '.switch is the positioned ancestor of its input');
|
||||
assert.match(input, /position:\s*absolute/);
|
||||
assert.equal(px(input, 'width'), 1); assert.equal(px(input, 'height'), 1);
|
||||
assert.match(input, /clip:\s*rect\(0 0 0 0\)|clip-path:\s*inset\(50%\)/, 'the clip pattern');
|
||||
assert.match(input, /overflow:\s*hidden/);
|
||||
assert.equal(/display:\s*none|visibility:\s*hidden/.test(input), false, 'display none would take the input out of the tab order');
|
||||
assert.match(ruleOf('.switch input:focus-visible+.track'), /outline/, 'focus stays visible on the track');
|
||||
});
|
||||
|
||||
test('the knob sits inside the track at off and at on, both sizes, and neither theme block touches the switch geometry', () => {
|
||||
const track = ruleOf('.switch .track'), knob = ruleOf('.switch .track::after'), on = ruleOf('.switch input:checked+.track::after');
|
||||
const big = ruleOf('.switch.big .track'), bigKnob = ruleOf('.switch.big .track::after'), bigOn = ruleOf('.switch.big input:checked+.track::after');
|
||||
const inside = (tw, th, kw, kh, left, top, dx, label) => {
|
||||
assert.ok(left >= 0 && top >= 0, label + ': the knob starts inside (left ' + left + ', top ' + top + ')');
|
||||
assert.ok(left + kw <= tw && top + kh <= th, label + ' off: knob ' + kw + 'x' + kh + ' at ' + left + ',' + top + ' in a ' + tw + 'x' + th + ' track');
|
||||
assert.ok(left + dx + kw <= tw, label + ' on: knob right edge ' + (left + dx + kw) + ' in a ' + tw + ' track');
|
||||
assert.ok(left + dx >= 0, label + ' on: knob left edge ' + (left + dx));
|
||||
};
|
||||
assert.match(track, /position:\s*relative/, 'the track positions its knob');
|
||||
inside(px(track, 'width'), px(track, 'height'), px(knob, 'width'), px(knob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(on), 'the 40 px switch');
|
||||
inside(px(big, 'width'), px(big, 'height'), px(bigKnob, 'width'), px(bigKnob, 'height'), px(knob, 'left'), px(knob, 'top'), translate(bigOn), 'the big switch');
|
||||
// the two theme blocks (prefers-color-scheme light, data-theme light) only set tokens: no .switch or .track rule inside them
|
||||
for (const m of css.matchAll(/@media \(prefers-color-scheme:light\)\{:root:not\(\[data-theme="dark"\]\)\{[^}]*\}\}|:root\[data-theme="light"\]\{[^}]*\}/g)) assert.equal(/\.switch|\.track/.test(m[0]), false, 'a theme block touches the switch');
|
||||
assert.equal(cssRules(css).some((r) => /\.switch|\.track/.test(r.sel) && /@media \(prefers-color-scheme/.test(r.sel)), false);
|
||||
});
|
||||
|
||||
// ---------- the shared marks module (brand/marks/vendor-marks.mjs, for the site) ----------
|
||||
test('brand/marks/vendor-marks.mjs carries the app\'s marks and tokens verbatim, plus the Windows mark in the same treatment', async () => {
|
||||
const mod = await import(join(dirname(fileURLToPath(import.meta.url)), '../../../brand/marks/vendor-marks.mjs'));
|
||||
assert.deepEqual(mod.MARKS, V.MARKS, 'the glyph strings (regenerate with node brand/marks/regen.mjs)');
|
||||
assert.deepEqual(mod.VENDORS, V.VENDORS);
|
||||
assert.deepEqual(mod.WELL_ALPHA, { dark: V.WELL_ALPHA_DARK, light: V.WELL_ALPHA_LIGHT });
|
||||
assert.equal(mod.OS_MARKS.macos, V.MARKS.apple);
|
||||
assert.match(mod.OS_MARKS.windows, /^<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false" fill="currentColor"><path /);
|
||||
assert.ok(Buffer.byteLength(mod.OS_MARKS.windows) < 2048);
|
||||
assert.equal(mod.markHtml('amd'), V.markHtml({ vendor: 'amd' }));
|
||||
assert.equal(mod.markHtml('zzz'), V.markHtml({ vendor: 'zzz' }));
|
||||
// the tokens the module prints are the ones app.css carries
|
||||
for (const line of mod.tokensCss().split('\n')) { const inner = /\{([^{}]*)\}\}?$/.exec(line)[1]; assert.ok(css.includes(inner), 'app.css carries: ' + inner.slice(0, 60)); }
|
||||
});
|
||||
|
||||
// ---------- the Earnings card, tidied (the project lead, 7 October 2026: "remove all the type a price stuff") ----------
|
||||
const indexHtml = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html'), 'utf8');
|
||||
const earningsCard = () => { const a = indexHtml.indexOf('id="page-earnings"'); return indexHtml.slice(a, indexHtml.indexOf('id="ladder-card"', a)); };
|
||||
|
||||
test('no price input exists: no £ per IGN field, no "Use it", no remembered price, no money line derived from a typed price (known-failed on 0.3.20)', () => {
|
||||
const ec = earningsCard();
|
||||
assert.equal(/e-price|Type a price|Use it|price-row|per IGN/.test(ec), false, 'the markup');
|
||||
assert.equal(/<input[^>]*type="number"/.test(ec), false, 'no number input on the card');
|
||||
assert.equal(/ign_price|ignPrice|saveIgnPrice|e-price/.test(src), false, 'the setting and its handlers');
|
||||
const e = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal('price' in e, false); assert.equal(V.FIELDS.price, undefined);
|
||||
for (const k of Object.keys(e)) assert.equal(/£|GBP|pounds?\b/.test((e[k] && e[k].text) || ''), k === 'cost', k + ' carries money only on the electricity cell');
|
||||
});
|
||||
|
||||
test('the headline reads the day rate with its reason line, then blocks and IGN this run', () => {
|
||||
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
|
||||
const s = stateOf({ uptime_s: 5400, mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
|
||||
const e = V.earningsLines(s, c, 28, T5);
|
||||
assert.equal(e.day.text, '7,680 IGN a day');
|
||||
assert.equal(e.day.sub, 'at your last hour’s rate: 4 blocks at 80.00 IGN each · about 6,912 IGN expected at 100 MH/s');
|
||||
assert.equal(e.run.text, '140 blocks this run');
|
||||
assert.equal(e.run.sub, '11,200 IGN at today’s reward · 14 shards, 16.10 IGN · 1 h 30 min');
|
||||
const f = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal(f.day.text, 'about 6,912 IGN a day');
|
||||
assert.equal(f.day.sub, 'expected at 100 MH/s, 0.100% of today’s network · no block in the last hour yet');
|
||||
const n = V.earningsLines(stateOf(), { ok: false, error: 'neither answered' }, 28, T5);
|
||||
assert.equal(n.day.text, 'IGN a day: reading the network'); assert.equal(n.day.sub, 'neither answered');
|
||||
// the markup: the headline is the first thing on the card, the run line second
|
||||
const ec = earningsCard();
|
||||
assert.ok(ec.indexOf('id="e-day"') < ec.indexOf('id="e-run"') && ec.indexOf('id="e-run"') < ec.indexOf('class="earn-three"'), 'headline, run line, row of three, in that order');
|
||||
assert.ok(ec.indexOf('class="earn-three"') < ec.indexOf('id="s-devfee"'), 'the dev-fee switch is last');
|
||||
assert.equal(/id="r-devfee-line"/.test(ec), false, 'one sentence on the dev fee, no second help line');
|
||||
for (const line of [e.day.sub, e.run.sub, f.day.sub]) assert.equal(/—|–/.test(line), false, 'no dashes: ' + line);
|
||||
});
|
||||
|
||||
test('the row of three renders: weight rung, electricity and lifetime, each with a quiet line', () => {
|
||||
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
|
||||
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 2592, accepted_session: 140, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
|
||||
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
|
||||
const e = V.earningsLines(s, c, 28, T5);
|
||||
assert.equal(e.weight.text, 'rank 41 of 1,204 keys');
|
||||
assert.equal(e.weight.sub, '2,592 blocks in 30 days · 23 of 30 days · 0.100% of the network');
|
||||
assert.equal(e.cost.text, '£1.01 a day');
|
||||
assert.equal(e.cost.sub, 'at 28p/kWh for 150 W · 2,133 IGN per kWh');
|
||||
assert.equal(e.lifetime.text, '2,592 blocks');
|
||||
assert.equal(e.lifetime.sub, '207,360 IGN at today’s reward');
|
||||
const noPrice = V.earningsLines(s, c, 0, T5);
|
||||
assert.equal(noPrice.cost.text, '150 W'); assert.equal(noPrice.cost.sub, 'set a price in Settings · 2,133 IGN per kWh');
|
||||
const fresh = V.earningsLines(stateOf(), chainOf(), 28, T5);
|
||||
assert.equal(fresh.weight.text, 'not in the weight table yet');
|
||||
assert.equal(fresh.lifetime.text, '0 blocks');
|
||||
assert.equal(V.earningsLines(stateOf({ mining: { ...stateOf().mining, watts_total: 0, cards: [] } }), chainOf(), 28, T5).cost.sub, 'no card reports its draw');
|
||||
assert.equal(e.devFee(true, 12), 'Dev fee: 1 block in 100 pays the people who make this app (12 blocks so far).');
|
||||
assert.equal(e.devFee(false, 0), 'Dev fee off. Every block pays your address.');
|
||||
const ec = earningsCard(), three = ec.slice(ec.indexOf('class="earn-three"'), ec.indexOf('id="s-devfee"'));
|
||||
assert.equal((three.match(/class="earn-cell"/g) || []).length, 3);
|
||||
for (const id of ['e-rung', 'e-cost', 'e-blocks']) assert.ok(three.includes('id="' + id + '"'), id);
|
||||
assert.ok(/\.earn-three\{[^}]*grid-template-columns:\s*repeat\(3/.test(css), 'three columns in app.css');
|
||||
});
|
||||
|
||||
test('the "Prove instead of mining" row shows only on an under-12 GB machine and names the choice (main, 7 October 2026)', () => {
|
||||
assert.equal(V.proveInsteadWords({ under_12gb: false }, { prove_instead: false }).show, false);
|
||||
const off = V.proveInsteadWords({ under_12gb: true }, { prove_instead: false });
|
||||
assert.equal(off.show, true); assert.equal(off.on, false); assert.match(off.help, /never both/);
|
||||
const on = V.proveInsteadWords({ under_12gb: true }, { prove_instead: true });
|
||||
assert.equal(on.show, true); assert.equal(on.on, true); assert.match(on.help, /held off/);
|
||||
});
|
||||
|
||||
// ---------- currency (currency-21, the project lead, 7 October 2026: "£/day is for UK; we need other currencies") ----------
|
||||
// No IP lookups: the OS locale's region picks the currency and the default tariff at first run; a Settings override
|
||||
// (currency plus the price per kWh in it) wins and lives in the engine's settings file; every money line goes through
|
||||
// one Intl formatter; the user's own tariff is the number, so no exchange rate exists anywhere.
|
||||
const uiDir = dirname(fileURLToPath(import.meta.url));
|
||||
const pageHtml = readFileSync(join(uiDir, 'index.html'), 'utf8');
|
||||
const rustSrc = (f) => readFileSync(join(uiDir, '../src/' + f), 'utf8');
|
||||
|
||||
test('a de-DE machine reads EUR with the EU tariff from its locale, before any choice is made (known-failed on 0.3.21)', () => {
|
||||
const ch = V.currencyChoice({ region: '', currency: '' }, 'de-DE');
|
||||
assert.equal(ch.code, 'EUR'); assert.equal(ch.region, 'de'); assert.equal(ch.source, 'locale'); assert.equal(ch.prompt, false);
|
||||
assert.equal(V.regionOf('de').price, 38.69); assert.match(V.regionOf('de').source, /Eurostat/);
|
||||
assert.equal(V.regionFromLocale('de-DE'), 'de'); assert.equal(V.regionFromLocale('en-GB'), 'gb'); assert.equal(V.regionFromLocale('de'), 'de'); assert.equal(V.regionFromLocale('fr-CA'), 'ca'); assert.equal(V.regionFromLocale(''), '');
|
||||
const ca = V.currencyChoice({}, 'fr-CA'); assert.equal(ca.code, 'CAD'); assert.equal(V.regionOf('ca').name, 'Canada'); assert.equal(V.regionOf('ca').cur, 'CAD');
|
||||
assert.equal(V.currencyChoice({}, 'en-AU').code, 'AUD');
|
||||
assert.equal(V.currencyChoice({}, 'fr-FR').code, 'EUR'); assert.ok(V.regionOf('fr').price > 0, 'an EU member without its own row takes the EU average'); assert.match(V.regionOf('fr').source, /EU average/);
|
||||
assert.equal(V.currencyChoice({}, 'sv-SE').code, 'SEK'); assert.equal(V.regionOf('se').price, 0, 'no table price outside the euro: the miner types one');
|
||||
// one formatter, the symbol placed as the locale places it, no hand-built money string left
|
||||
assert.equal(V.fmtMoney(1.5, 'EUR', 'de-DE'), '1,50 €'); assert.equal(V.fmtMoney(1.5, 'EUR', 'en-IE'), '€1.50');
|
||||
assert.equal(V.fmtMoney(1234.5, 'USD', 'en-US'), '$1,235'); assert.equal(V.fmtMoney(1.5, 'CAD', 'en-CA'), '$1.50'); assert.equal(V.fmtMoney(1.5, 'JPY', 'en-US'), '¥2'); assert.equal(V.fmtMoney(null, 'GBP', 'en-GB'), '');
|
||||
V.setLocale('de-DE'); V.setRegion('de'); assert.equal(V.money(1.5), '1,50 €'); V.setLocale('en-GB'); V.setRegion('');
|
||||
assert.equal((src.match(/currency\.symbol \+ \(/g) || []).length, 0, 'money() no longer concatenates a symbol');
|
||||
});
|
||||
|
||||
test('a user override wins over the locale and the region, and survives restart through the engine settings file', () => {
|
||||
const ch = V.currencyChoice({ region: 'gb', currency: 'USD' }, 'de-DE');
|
||||
assert.equal(ch.code, 'USD'); assert.equal(ch.source, 'override'); assert.equal(ch.prompt, false);
|
||||
assert.equal(V.currencyChoice({ region: 'gb', currency: '' }, 'de-DE').code, 'GBP', 'a chosen region beats the locale');
|
||||
assert.equal(V.currencyChoice({ region: 'gb', currency: 'zzz' }, 'en-GB').code, 'GBP', 'an unknown override is ignored');
|
||||
V.setLocale('en-GB'); V.setCurrency('USD'); assert.equal(V.money(1.5), '$1.50'); assert.equal(V.currencyNow().code, 'USD'); V.setCurrency(''); V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50'); V.setRegion('');
|
||||
// survives restart: a field of the engine's settings file, carried in the state the UI reads, set through api/region
|
||||
assert.match(rustSrc('config.rs'), /pub currency: String/); assert.match(rustSrc('state.rs'), /pub currency: String/);
|
||||
assert.match(rustSrc('server.rs'), /body\.get\("currency"\)/); assert.match(rustSrc('engine.rs'), /Cmd::Region\(region, price, currency\)/);
|
||||
assert.match(rustSrc('config.rs'), /fn currency_survives_a_round_trip|currency_round_trip/, 'the Rust round-trip test exists');
|
||||
// the Settings card: a currency picker with "follow the region" first; the price unit follows the pick
|
||||
assert.ok(pageHtml.includes('id="s-currency"'), 'the picker');
|
||||
assert.deepEqual(V.minorOf('GBP'), { word: 'pence', abbr: 'p', digits: 2 }); assert.equal(V.minorOf('EUR').word, 'euro cents'); assert.equal(V.minorOf('CAD').abbr, 'c'); assert.equal(V.minorOf('JPY').digits, 0); assert.equal(V.minorOf('JPY').word, 'yen');
|
||||
assert.ok(V.CURRENCY_CODES.indexOf('GBP') === 0 && V.CURRENCY_CODES.slice(0, 5).join() === 'GBP,EUR,USD,CAD,AUD' && V.CURRENCY_CODES.length > 20, 'the five first, then the long tail');
|
||||
});
|
||||
|
||||
test('a locale with no currency row falls back to USD with the override prompt visible', () => {
|
||||
const eo = V.currencyChoice({}, 'eo');
|
||||
assert.equal(eo.code, 'USD'); assert.equal(eo.prompt, true); assert.equal(eo.source, 'fallback');
|
||||
const aq = V.currencyChoice({}, 'en-AQ'); assert.equal(aq.code, 'USD'); assert.equal(aq.prompt, true);
|
||||
assert.equal(V.currencyChoice({}, 'en-GB').prompt, false);
|
||||
assert.equal(V.currencyChoice({ region: 'other', currency: '' }, 'eo').prompt, true, '"Somewhere else" with no currency still asks');
|
||||
assert.equal(V.currencyChoice({ region: 'other', currency: 'USD' }, 'eo').prompt, false, 'an answer closes the prompt');
|
||||
assert.match(V.currencyAsk(eo), /could not tell your currency/);
|
||||
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
|
||||
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -109,7 +109,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
}
|
||||
|
||||
func buildWindow() {
|
||||
let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize
|
||||
// window-22 (7 October 2026, the one rule with app/windows/opening_size.h): the PRIMARY screen's work area, 80 percent
|
||||
// wide capped at 1440 up to a 1920-wide display and 1920 beyond, height from the width at 16:10 bounded by the work
|
||||
// area less 40, never wider than 1.6 times the height, never under 900 by 600, never over the work area
|
||||
let work = (NSScreen.screens.first ?? NSScreen.main)?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
||||
func openingSize(_ workW: CGFloat, _ workH: CGFloat) -> NSSize {
|
||||
let minW = min(workW, 900), minH = min(workH, 600), capW: CGFloat = workW <= 1920 ? 1440 : 1920
|
||||
var w = min(floor(workW * 0.8), capW); var h = min(floor(w / 1.6), workH - 40)
|
||||
w = max(w, minW); h = max(h, minH); w = min(w, floor(h * 1.6)); w = min(w, workW); h = min(h, workH)
|
||||
return NSSize(width: w, height: h)
|
||||
}
|
||||
func fits(_ f: NSRect) -> Bool { f.width >= 900 && f.height >= 600 && f.width <= work.width && f.height <= work.height && f.width <= f.height * 1.632 }
|
||||
let opening = openingSize(work.width, work.height)
|
||||
let size = snapshotPath == nil ? opening : snapshotSize
|
||||
window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
|
||||
window.title = "Igneum Miner"
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
|
@ -117,7 +129,11 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
window.isMovableByWindowBackground = true
|
||||
window.backgroundColor = obsidian
|
||||
window.minSize = NSSize(width: 900, height: 600)
|
||||
window.center()
|
||||
if snapshotPath == nil {
|
||||
// the remembered frame is kept only when it fits the primary work area and the aspect cap, else discarded
|
||||
if !window.setFrameUsingName("IgneumMinerMain") || !fits(window.frame) { window.setContentSize(opening); window.center() }
|
||||
window.setFrameAutosaveName("IgneumMinerMain")
|
||||
} else { window.center() }
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
|
|
@ -209,6 +225,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
}
|
||||
} else if line.hasPrefix("STATE ") {
|
||||
applyState(String(line.dropFirst(6)))
|
||||
} else if line.hasPrefix("SHOT ") {
|
||||
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
|
||||
} else if line.hasPrefix("FATAL ") {
|
||||
fail(String(line.dropFirst(6)))
|
||||
} else if line == "EXIT" {
|
||||
|
|
@ -322,6 +341,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
|
||||
}
|
||||
|
||||
// ---- a screenshot on request (the SHOT line), the app keeps running ----
|
||||
func shotTo(_ path: String) {
|
||||
let conf = WKSnapshotConfiguration()
|
||||
conf.rect = web.bounds
|
||||
web.takeSnapshot(with: conf) { image, error in
|
||||
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
|
||||
return
|
||||
}
|
||||
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- snapshot ----
|
||||
func snapshot(to path: String) {
|
||||
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
#define _UNICODE
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#include "opening_size.h"
|
||||
#include <shellapi.h>
|
||||
#include <shlwapi.h>
|
||||
#include <dbt.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
|
|
@ -33,17 +35,34 @@ using namespace Microsoft::WRL;
|
|||
#define ID_TRAY_PAUSE 1002
|
||||
#define ID_TRAY_QUIT 1003
|
||||
#define ID_QUIT_TIMER 7
|
||||
// MF-11 (7 October 2026): an engine that stops without a quit is started again (10 s, then 60 s after three in ten
|
||||
// minutes); a second "Igneum Miner.exe" that finds this window asks it to do so now (WM_ENGINE_RESTART), instead of
|
||||
// showing a window that says "the engine stopped" with nothing mining behind it.
|
||||
#define ID_RESTART_TIMER 8
|
||||
#define WM_ENGINE_RESTART (WM_APP + 3)
|
||||
#define RESTART_SOON_MS 10000
|
||||
#define RESTART_SLOW_MS 60000
|
||||
#define RESTART_WINDOW_MS 600000
|
||||
#define IDI_APP 1
|
||||
|
||||
static HWND g_hwnd = nullptr;
|
||||
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
|
||||
static ComPtr<ICoreWebView2Controller> g_controller;
|
||||
static ComPtr<ICoreWebView2> g_webview;
|
||||
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
|
||||
static void capturePreview(const std::wstring& path) {
|
||||
if (!g_webview) return;
|
||||
ComPtr<IStream> stream;
|
||||
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
|
||||
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
|
||||
}
|
||||
static std::wstring g_url, g_status = L"starting the engine";
|
||||
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
|
||||
static NOTIFYICONDATAW g_nid = {};
|
||||
static std::wstring g_trayTitle = L"Igneum Miner";
|
||||
static ULONGLONG g_quitStarted = 0;
|
||||
static int g_restarts = 0; // engine restarts inside the current window
|
||||
static ULONGLONG g_restartWindowStart = 0; // when that window opened
|
||||
|
||||
static std::wstring widen(const std::string& s) {
|
||||
if (s.empty()) return L"";
|
||||
|
|
@ -253,6 +272,47 @@ static bool startEngine() {
|
|||
return true;
|
||||
}
|
||||
|
||||
// The engine's handles, closed before another engine is started (the reader thread has already left: it posts the
|
||||
// "gone" line only after the pipe closed).
|
||||
static void closeEngine() {
|
||||
if (g_engineIn) { CloseHandle(g_engineIn); g_engineIn = nullptr; }
|
||||
if (g_engineOut) { CloseHandle(g_engineOut); g_engineOut = nullptr; }
|
||||
if (g_engine) { CloseHandle(g_engine); g_engine = nullptr; }
|
||||
}
|
||||
|
||||
// Starts the engine again after it stopped on its own. Three restarts inside ten minutes come 10 s apart; from the
|
||||
// fourth they come a minute apart, for ever: a miner that sits stopped is a miner lost (plug, tune, play).
|
||||
static void scheduleRestart() {
|
||||
ULONGLONG now = GetTickCount64();
|
||||
if (g_restartWindowStart == 0 || now - g_restartWindowStart > RESTART_WINDOW_MS) { g_restartWindowStart = now; g_restarts = 0; }
|
||||
g_restarts++;
|
||||
UINT delay = g_restarts <= 3 ? RESTART_SOON_MS : RESTART_SLOW_MS;
|
||||
wchar_t buf[160];
|
||||
swprintf_s(buf, L"The engine stopped. Starting it again in %u s (restart %d).", delay / 1000, g_restarts);
|
||||
g_status = buf;
|
||||
setTray(L"Igneum Miner: starting the engine again");
|
||||
repaintStatus();
|
||||
SetTimer(g_hwnd, ID_RESTART_TIMER, delay, nullptr);
|
||||
}
|
||||
|
||||
static void restartEngineNow() {
|
||||
KillTimer(g_hwnd, ID_RESTART_TIMER);
|
||||
if (g_quitting || !g_exited) return;
|
||||
closeEngine();
|
||||
g_exited = false;
|
||||
g_url.clear();
|
||||
if (startEngine()) {
|
||||
g_status = L"";
|
||||
setTray(L"Igneum Miner: starting");
|
||||
repaintStatus();
|
||||
} else {
|
||||
g_exited = true;
|
||||
g_status = L"igneum-app.exe is missing next to this program. Run the installer again.";
|
||||
repaintStatus();
|
||||
scheduleRestart();
|
||||
}
|
||||
}
|
||||
|
||||
static void showTrayMenu() {
|
||||
HMENU m = CreatePopupMenu();
|
||||
AppendMenuW(m, MF_STRING, ID_TRAY_OPEN, L"Open Igneum Miner");
|
||||
|
|
@ -282,11 +342,46 @@ static void beginQuit() {
|
|||
}
|
||||
}
|
||||
|
||||
// scaling-21 (7 October 2026, the project lead: "the miner needs some scaling so more is visible in the initial window"): the window
|
||||
// opens at about 80 percent of the work area, capped at 1440 by 900 on a display up to 1920 wide and scaled up with the
|
||||
// display beyond that; once the user resizes, the size is remembered per machine under HKCU\Software\Igneum\Miner
|
||||
// (WindowW, WindowH) and used on the next start while it still fits the work area. The Mac window does the same
|
||||
// through its frame autosave name (app/mac/IgneumMiner.swift).
|
||||
static const wchar_t* kSizeKey = L"Software\\Igneum\\Miner";
|
||||
static void saveWindowSize(HWND hwnd) {
|
||||
if (IsIconic(hwnd) || IsZoomed(hwnd)) return;
|
||||
RECT r; if (!GetWindowRect(hwnd, &r)) return;
|
||||
DWORD w = (DWORD)(r.right - r.left), h = (DWORD)(r.bottom - r.top);
|
||||
if (w < 900 || h < 600 || w > (DWORD)(h * 1.6)) return;
|
||||
HKEY k; if (RegCreateKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &k, nullptr) != ERROR_SUCCESS) return;
|
||||
RegSetValueExW(k, L"WindowW", 0, REG_DWORD, (const BYTE*)&w, sizeof(w));
|
||||
RegSetValueExW(k, L"WindowH", 0, REG_DWORD, (const BYTE*)&h, sizeof(h));
|
||||
RegCloseKey(k);
|
||||
}
|
||||
static void openingSize(int& w, int& h, int& sx, int& sy) {
|
||||
// the PRIMARY monitor's work area, never the virtual desktop (window-22: a two-monitor span opened the app super wide)
|
||||
RECT work = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) };
|
||||
POINT origin = { 0, 0 }; HMONITOR mon = MonitorFromPoint(origin, MONITOR_DEFAULTTOPRIMARY);
|
||||
MONITORINFO mi = { sizeof(mi) }; if (mon && GetMonitorInfoW(mon, &mi)) work = mi.rcWork; else SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
||||
int ww = work.right - work.left, wh = work.bottom - work.top;
|
||||
HKEY k; DWORD sw = 0, sh = 0, n = sizeof(DWORD);
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, KEY_QUERY_VALUE, &k) == ERROR_SUCCESS) {
|
||||
RegQueryValueExW(k, L"WindowW", nullptr, nullptr, (LPBYTE)&sw, &n); n = sizeof(DWORD);
|
||||
RegQueryValueExW(k, L"WindowH", nullptr, nullptr, (LPBYTE)&sh, &n);
|
||||
RegCloseKey(k);
|
||||
}
|
||||
igneum_opening_size(ww, wh, (int)sw, (int)sh, &w, &h);
|
||||
sx = work.left + (ww - w) / 2; sy = work.top + (wh - h) / 2;
|
||||
}
|
||||
|
||||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
switch (msg) {
|
||||
case WM_SIZE:
|
||||
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
|
||||
return 0;
|
||||
case WM_EXITSIZEMOVE:
|
||||
saveWindowSize(hwnd);
|
||||
return 0;
|
||||
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
|
||||
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
|
||||
return 0;
|
||||
|
|
@ -294,9 +389,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
if (wp == 1) {
|
||||
g_exited = true;
|
||||
if (g_quitting) { DestroyWindow(hwnd); return 0; }
|
||||
g_status = L"The engine stopped. Close this window and open Igneum Miner again.";
|
||||
setTray(L"Igneum Miner: stopped");
|
||||
repaintStatus();
|
||||
// not a quit of ours: the engine died, or a local caller (the installer, a job) asked it to stop and nothing
|
||||
// will start it again; this window does (MF-11)
|
||||
scheduleRestart();
|
||||
return 0;
|
||||
}
|
||||
std::string* line = (std::string*)lp;
|
||||
|
|
@ -308,6 +403,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
applyState(line->substr(6));
|
||||
} else if (line->rfind("ELEVATE ", 0) == 0) {
|
||||
runElevated(widen(line->substr(8)));
|
||||
} else if (line->rfind("SHOT ", 0) == 0) {
|
||||
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||
capturePreview(widen(line->substr(5)));
|
||||
} else if (line->rfind("FATAL ", 0) == 0) {
|
||||
g_status = widen(line->substr(6));
|
||||
repaintStatus();
|
||||
|
|
@ -319,7 +417,21 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
delete line;
|
||||
return 0;
|
||||
}
|
||||
case WM_ENGINE_RESTART:
|
||||
// a second "Igneum Miner.exe" (the update helper, the Start Menu, the relay agent's start-app) found this window:
|
||||
// an engine that is gone starts now, not in its backoff
|
||||
if (g_exited && !g_quitting) restartEngineNow();
|
||||
return 0;
|
||||
case WM_QUERYENDSESSION:
|
||||
// the Restart Manager (an installer's /CLOSEAPPLICATIONS) or a sign-out: this window closes for real, it does not
|
||||
// hide to the tray (WM_CLOSE below is the user's X)
|
||||
beginQuit();
|
||||
return TRUE;
|
||||
case WM_ENDSESSION:
|
||||
if (wp) { if (g_engine && !g_exited) sendEngine("quit"); }
|
||||
return 0;
|
||||
case WM_TIMER:
|
||||
if (wp == ID_RESTART_TIMER) { restartEngineNow(); return 0; }
|
||||
if (wp == ID_QUIT_TIMER) {
|
||||
DWORD code = 0;
|
||||
bool gone = !g_engine || (GetExitCodeProcess(g_engine, &code) && code != STILL_ACTIVE);
|
||||
|
|
@ -424,7 +536,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
|||
HANDLE once = CreateMutexW(nullptr, TRUE, L"Local\\IgneumMinerWindow");
|
||||
if (GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
HWND other = FindWindowW(L"IgneumMinerWindow", nullptr);
|
||||
if (other) { ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
|
||||
if (other) { PostMessageW(other, WM_ENGINE_RESTART, 0, 0); ShowWindow(other, SW_SHOW); SetForegroundWindow(other); }
|
||||
return 0;
|
||||
}
|
||||
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
|
|
@ -436,8 +548,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
|||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||
wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14));
|
||||
RegisterClassW(&wc);
|
||||
int w = 1120, h = 820;
|
||||
int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2;
|
||||
int w, h, sx, sy; openingSize(w, h, sx, sy);
|
||||
g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr);
|
||||
ShowWindow(g_hwnd, SW_SHOW);
|
||||
|
||||
|
|
@ -462,7 +573,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) {
|
|||
TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
if (g_engine) { CloseHandle(g_engine); }
|
||||
closeEngine();
|
||||
CloseHandle(once);
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
|
|
|
|||
26
app/windows/opening_size.h
Normal file
26
app/windows/opening_size.h
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* The opening window size, one rule for both hosts (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super
|
||||
wide?"). Inputs: the PRIMARY monitor's work area (never the virtual desktop spanning two monitors) and the remembered
|
||||
frame (0 when none). Width first: 80 percent of the work area, at most 1440 on a display up to 1920 wide and at most 1920
|
||||
beyond, whatever the display; height from the width at 16:10, bounded by the work area less 40 px; then the window is
|
||||
never wider than 1.6 times its height, never under 900 by 600 unless the work area itself is smaller, never over the work
|
||||
area. A remembered frame is kept only when it fits the work area, the minimum and the aspect cap, else discarded (a
|
||||
super-wide frame from an earlier host must not come back). Pure C99, no Windows headers: host.cpp includes it,
|
||||
opening_size_test.c compiles it on the gate, IgneumMiner.swift mirrors it line for line. */
|
||||
#ifndef IGNEUM_OPENING_SIZE_H
|
||||
#define IGNEUM_OPENING_SIZE_H
|
||||
static void igneum_opening_size(int workW, int workH, int savedW, int savedH, int* outW, int* outH) {
|
||||
int minW = workW < 900 ? workW : 900, minH = workH < 600 ? workH : 600;
|
||||
int capW = workW <= 1920 ? 1440 : 1920;
|
||||
int w = (int)(workW * 0.8); if (w > capW) w = capW;
|
||||
int h = (int)(w / 1.6); if (h > workH - 40) h = workH - 40;
|
||||
if (w < minW) w = minW;
|
||||
if (h < minH) h = minH;
|
||||
if (w > (int)(h * 1.6)) w = (int)(h * 1.6);
|
||||
if (w > workW) w = workW;
|
||||
if (h > workH) h = workH;
|
||||
/* a remembered frame: the user's own size, kept when it fits; the aspect cap carries a 2 percent tolerance so a hand-sized
|
||||
1329 by 825 (PC 2, 7 October 2026, aspect 1.611) is not thrown away for a few pixels */
|
||||
if (savedW >= 900 && savedH >= 600 && savedW <= workW && savedH <= workH && savedW <= (int)(savedH * 1.632)) { w = savedW; h = savedH; }
|
||||
*outW = w; *outH = h;
|
||||
}
|
||||
#endif
|
||||
30
app/windows/opening_size_test.c
Normal file
30
app/windows/opening_size_test.c
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* The opening window size (window-22, 7 October 2026, the project lead on PC 2: "why did the app open super wide?"): the rule in
|
||||
opening_size.h, shared by the Windows host (host.cpp) and mirrored in the Mac window (IgneumMiner.swift). Known-failed
|
||||
first: on 0.3.21's rule a 3440 by 1440 work area opened 2752 by 1152 and a 3840 by 1080 span opened 3072 by 864
|
||||
(the test could not compile before the header existed).
|
||||
cc -std=c99 -Wall -Wextra -o t app/windows/opening_size_test.c && ./t */
|
||||
#include <stdio.h>
|
||||
#include "opening_size.h"
|
||||
static int fails = 0;
|
||||
static void check(const char* name, int ww, int wh, int sw, int sh, int ew, int eh) {
|
||||
int w = 0, h = 0; igneum_opening_size(ww, wh, sw, sh, &w, &h);
|
||||
if (w != ew || h != eh) { printf("FAIL %s: work %dx%d saved %dx%d -> %dx%d, wanted %dx%d\n", name, ww, wh, sw, sh, w, h, ew, eh); fails++; }
|
||||
else printf("ok %s: %dx%d\n", name, w, h);
|
||||
}
|
||||
int main(void) {
|
||||
check("1080p desk: 1440 by 900", 1920, 1040, 0, 0, 1440, 900);
|
||||
check("1440p monitor: width capped at 1920, 16:10", 2560, 1400, 0, 0, 1920, 1200);
|
||||
check("4K: width capped at 1920 regardless of display", 3840, 2120, 0, 0, 1920, 1200);
|
||||
check("ultrawide 3440 by 1440: never wider than 1.6 times the height", 3440, 1400, 0, 0, 1920, 1200);
|
||||
check("two monitors spanned 3840 by 1080, if ever read as one area: the aspect cap holds", 3840, 1040, 0, 0, 1600, 1000);
|
||||
check("a remembered frame that fits is kept", 1920, 1040, 1400, 900, 1400, 900);
|
||||
check("PC 2, 7 October 2026: a 5120 by 2160 panel at 200 percent reads as 2560 by 1032; 0.3.21 opened 1920 by 826", 2560, 1032, 0, 0, 1587, 992);
|
||||
check("PC 2: the hand-sized 1329 by 825 (aspect 1.611) is kept under the 2 percent tolerance", 2560, 1032, 1329, 825, 1329, 825);
|
||||
check("a remembered frame at aspect 1.7 is discarded", 2560, 1032, 1700, 1000, 1587, 992);
|
||||
check("a remembered super-wide frame from the take-4 host is discarded", 1920, 1040, 2752, 1152, 1440, 900);
|
||||
check("a remembered frame wider than 1.6 times its height is discarded", 1920, 1040, 1800, 700, 1440, 900);
|
||||
check("a remembered frame under the minimum is discarded", 1920, 1040, 800, 500, 1440, 900);
|
||||
check("a small laptop: 80 percent wide, 16:10", 1280, 760, 0, 0, 1024, 640);
|
||||
check("a tiny work area: the work area itself", 800, 560, 0, 0, 800, 560);
|
||||
printf(fails ? "%d FAILED\n" : "all ok\n", fails); return fails ? 1 : 0;
|
||||
}
|
||||
|
|
@ -3,6 +3,6 @@
|
|||
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
|
||||
#ifndef IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.14"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.22"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,22,0
|
||||
#endif
|
||||
|
|
|
|||
14
brand/marks/regen.mjs
Normal file
14
brand/marks/regen.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
// Regenerates brand/marks/vendor-marks.mjs from app/igneum-app/ui/app.js (View.MARKS, View.VENDORS): run after any
|
||||
// change to the marks in the app, then commit both. Keeps the header and the OS marks; only the data blocks move.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const m = { exports: {} }; new Function('module', readFileSync(join(here, '../../app/igneum-app/ui/app.js'), 'utf8'))(m);
|
||||
const V = m.exports.View, p = join(here, 'vendor-marks.mjs');
|
||||
let s = readFileSync(p, 'utf8');
|
||||
s = s.replace(/export const VENDORS = [\s\S]*?;\n/, 'export const VENDORS = ' + JSON.stringify(V.VENDORS, null, 2) + ';\n');
|
||||
s = s.replace(/export const WELL_ALPHA = [^\n]*\n/, 'export const WELL_ALPHA = { dark: ' + V.WELL_ALPHA_DARK + ', light: ' + V.WELL_ALPHA_LIGHT + ' };\n');
|
||||
s = s.replace(/export const MARKS = [\s\S]*?\n\};\n/, 'export const MARKS = ' + JSON.stringify(V.MARKS, null, 2) + ';\n');
|
||||
writeFileSync(p, s); console.log('brand/marks/vendor-marks.mjs regenerated');
|
||||
65
brand/marks/vendor-marks.mjs
Normal file
65
brand/marks/vendor-marks.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Igneum vendor and OS marks (gpu-logos, 7 October 2026): the strings the miner app ships in app/igneum-app/ui/app.js
|
||||
// (View.MARKS and View.VENDORS), exported verbatim for the site and anything else that names the hardware.
|
||||
// view.test.mjs fails when this file and app.js drift apart, so edit app.js first and regenerate this file with
|
||||
// `node brand/marks/regen.mjs` (or copy the strings by hand; the test says which one moved).
|
||||
//
|
||||
// Each glyph: a hand-drawn simplified monochrome mark of the vendor's public geometry (never a copied logo file,
|
||||
// never a raster), 24 x 24 viewBox at 22 px, under 460 bytes, fill or stroke through currentColor so the element's
|
||||
// colour tints it. Nominative use that names the hardware; the ember accent is for state and never tints a brand.
|
||||
//
|
||||
// The treatment in the app (app.css, the block at the end): a 44 x 44 well, radius 12, background the vendor colour
|
||||
// at .14 alpha (dark) or .10 (light), a 1 px ring in the vendor colour at .45 alpha, the glyph in the full colour;
|
||||
// hover and focus-within add a 3 px halo of the well colour; nothing animates. The light hex of every vendor reads at
|
||||
// 3:1 or better on its well over white (nvidia 3.87, amd 5.01, intel 4.29, apple 7.52, gpu 4.65).
|
||||
//
|
||||
// Class names in the app: .badge.<vendor> (nvidia | amd | intel | apple | gpu), .badge.mini for a 26 px inline mark,
|
||||
// .gen for the series line under the name. Tokens: --mark-<vendor>, --mark-<vendor>-well, --mark-<vendor>-ring.
|
||||
|
||||
export const VENDORS = {
|
||||
"nvidia": {
|
||||
"label": "NVIDIA",
|
||||
"dark": "#8BE37A",
|
||||
"light": "#2F8A22"
|
||||
},
|
||||
"amd": {
|
||||
"label": "AMD Radeon",
|
||||
"dark": "#FF5A5A",
|
||||
"light": "#C41E2A"
|
||||
},
|
||||
"intel": {
|
||||
"label": "Intel",
|
||||
"dark": "#7CC4FF",
|
||||
"light": "#1C6FD6"
|
||||
},
|
||||
"apple": {
|
||||
"label": "Apple",
|
||||
"dark": "#E6E3DD",
|
||||
"light": "#4A4A50"
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU",
|
||||
"dark": "#9A9A9E",
|
||||
"light": "#6B6B70"
|
||||
}
|
||||
};
|
||||
export const WELL_ALPHA = { dark: 0.14, light: 0.1 };
|
||||
export const MARKS = {
|
||||
"nvidia": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M2.6 12C5 7.9 8.3 5.8 12 5.8s7 2.1 9.4 6.2c-2.4 4.1-5.7 6.2-9.4 6.2S5 16.1 2.6 12z\"/><path d=\"M15.6 12A3.6 3.6 0 1 0 12 15.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/></svg>",
|
||||
"amd": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M8 3h13v13l-4-4V7h-5z\"/><path d=\"M3 8l4 4v5h5l4 4H3z\"/></svg>",
|
||||
"intel": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"><path d=\"M7.4 7.6C4.9 8.8 3.4 10.5 3.4 12.3c0 3.4 4.3 5.9 9.8 5.9 4.5 0 7.6-1.6 7.6-3.9 0-1.4-1.3-2.6-3.5-3.3\"/><path d=\"M11.2 9.6v6.2\"/><circle cx=\"11.2\" cy=\"6.6\" r=\"1.1\" fill=\"currentColor\" stroke=\"none\"/></svg>",
|
||||
"apple": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M16.4 12.6c0-2.5 2-3.6 2.1-3.7-1.2-1.7-3-1.9-3.6-2-1.5-.2-3 .9-3.8.9-.8 0-2-.9-3.3-.8-1.7 0-3.2 1-4.1 2.5-1.8 3-.5 7.6 1.3 10.1.9 1.2 1.9 2.6 3.2 2.5 1.3 0 1.8-.8 3.3-.8 1.6 0 2 .8 3.3.8 1.4 0 2.3-1.2 3.1-2.5 1-1.4 1.4-2.8 1.4-2.9 0 0-2.7-1-2.9-4.1zM13.9 5.3c.7-.8 1.2-2 1-3.2-1 0-2.2.7-2.9 1.5-.6.7-1.2 1.9-1 3 1.1.1 2.2-.5 2.9-1.3z\"/></svg>",
|
||||
"gpu": "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"><rect x=\"5.5\" y=\"5.5\" width=\"13\" height=\"13\" rx=\"2.2\"/><rect x=\"9.5\" y=\"9.5\" width=\"5\" height=\"5\" rx=\"1\"/><path d=\"M9 2.5v3M12 2.5v3M15 2.5v3M9 18.5v3M12 18.5v3M15 18.5v3M2.5 9h3M2.5 12h3M2.5 15h3M18.5 9h3M18.5 12h3M18.5 15h3\"/></svg>"
|
||||
};
|
||||
// OS marks in the same treatment: Apple is the vendor glyph; Windows is the four slanted panes
|
||||
export const OS_MARKS = {
|
||||
macos: MARKS.apple,
|
||||
windows: "<svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" aria-hidden=\"true\" focusable=\"false\" fill=\"currentColor\"><path d=\"M3 5.6l7.3-1v7.1H3zM11.4 4.4L21 3v8.7h-9.6zM3 12.3h7.3v7.1L3 18.4zM11.4 12.3H21V21l-9.6-1.4z\"/></svg>"
|
||||
};
|
||||
export const OS_COLOURS = { macos: VENDORS.apple, windows: { label: 'Windows', dark: '#7CC4FF', light: '#1C6FD6' } };
|
||||
// the CSS tokens for both themes, as the app declares them
|
||||
export function tokensCss() {
|
||||
const line = (theme) => Object.entries(VENDORS).map(([v, c]) => { const h = c[theme], r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16), a = String(WELL_ALPHA[theme]).replace(/^0/, ''); return `--mark-${v}:${h};--mark-${v}-well:rgba(${r},${g},${b},${a});--mark-${v}-ring:rgba(${r},${g},${b},.45)`; }).join(';');
|
||||
return `:root{${line('dark')}}\n@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){${line('light')}}}\n:root[data-theme="light"]{${line('light')}}`;
|
||||
}
|
||||
// the well: <div class="badge nvidia"><svg…></svg></div>
|
||||
export function markHtml(vendor, size) { const v = MARKS[vendor] ? vendor : 'gpu'; return '<div class="badge ' + (size ? size + ' ' : '') + v + '" data-mark="' + v + '" title="' + VENDORS[v].label + '">' + MARKS[v] + '</div>'; }
|
||||
|
|
@ -2563,6 +2563,35 @@ task now runs ...Programs\Igneum Miner\igneum-app.exe --power-helper"), then the
|
|||
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
|
||||
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
|
||||
through the installed app. Ember closed 16:53Z.
|
||||
### 6 October 2026, 17:55Z to 17:57Z, the first segment above the restart cut from a snapshot-restored node on the new export (Mac)
|
||||
|
||||
A copy of node 1's data dir started on the shipper's 0.3.14 Mac binary (target-0314, release-0.3.14-node 4c6b129d) with
|
||||
`--igneum-exec-snapshot=node1-copy-snapshot.bin,0xac101f13...` (the hands' recovery file, tip 130,272) and node 1 as its
|
||||
peer: IBD of 8,049 blocks in 29 s, the executor resumed from the file and reached the tip 140,023 with no restart replay.
|
||||
`igneum_exportSegments [139951, 139959]` answered in under 0.1 s with 9 segments, `preState` 79 accounts, `execRestart`
|
||||
27,276. The branch exporter (exec-app-0314 bfebff1) cut all 8 blocks of segment 139,952..139,959 in under 0.1 s each:
|
||||
"replayed 8 segments from the account dump; every state root equals the node's". The first block's fixture carries
|
||||
pre-state root 0x64304441... and post-state root 0x60d60bba..., equal to node 1's eth_getBlockByNumber roots at 139,951
|
||||
and 139,952 (an independent node). Consequence: a prover on a snapshot-restored node (every hand, nine fleet boxes) cuts a
|
||||
provable fixture again with the 0.3.14 app and exporter; what is left for a paid record is the proof itself (the PCs or
|
||||
the fleet, not this Mac).
|
||||
|
||||
### 6 October 2026, 18:11Z to 18:13Z, the PC shape over p2p (Mac, `tools/lock/with-lock.sh run`)
|
||||
|
||||
Fork 1255c0f9. `node tools/exec-sync/net.mjs`: 18/18 in 117.5 s. Case 7: a node whose data dir holds the tip-0 pair the 0.3.13
|
||||
genesis replay left behind, with `--igneum-exec-snapshot=peer`, sets the pair aside (.bin.tip0), asks A over p2p and loads
|
||||
A's state with no hand action; its state root at 60 equals A's. `reorg.mjs` 18/18 in 262.1 s on the same binary; the exec
|
||||
suite 20. Consequence: a PC or a box left with the tip-0 pair recovers on its own once a hand serves (the 5-minute
|
||||
no-progress rule fires the ask even when the error text is new); until 0.3.14.1 the recovery file by hand is the way.
|
||||
|
||||
## 6 October 2026, 18:18Z: the shipped 0.3.14 app asked to tune itself on PC 1 (ember-installed-pc1-1): FAILED, no tune ran
|
||||
|
||||
Gate ok (0.3.14, Power control on, the task on the installed exe). Every card's "after" was its 17:55Z failure from
|
||||
the app's own first tune: 5090 and 4070 "did not take within 30 s (card reports 460 / 160 W, acknowledged true)",
|
||||
9070 XT "the card refused the setting"; the three "Tune now" requests sat queued behind the hour's back-off. Five
|
||||
causes (A to E) and their fixes in the plan's 0.3.14 window section; the window repeats on 0.3.15. The cards kept
|
||||
mining at the driver's held locks: 5090 222.9 W at 1845 MHz, 4070 75.9 W at 1860 MHz, 9070 XT 201 W. Lever-2 TUNING
|
||||
records during the window: 5090 135.6 MH/s at 236 W (0.574 MH/W), 4070 31.1 MH/s at 79 W (0.393).
|
||||
|
||||
## Prover tiers on real cards: the rented fleet, 6 October 2026 (branch gpu-fleet)
|
||||
|
||||
|
|
@ -2616,6 +2645,49 @@ USD 20 an hour on community pods, against a devnet of 1.16 GH/s.
|
|||
Consequence: the devnet's hash is rentable for the price of a dinner, so nothing on it is a security result; the counter-ASIC and
|
||||
finality work is tested there for correctness, not for cost. The cost argument only starts at the TH/s scale, where the rental
|
||||
market's supply (not its price) is the limit, and that number belongs in the litepaper with this caveat.
|
||||
### 6 October 2026, 20:38Z to 21:03Z, proof verification on the consensus path (fork exec-sync-0313 da2d17ec)
|
||||
|
||||
The in-process SP1 verifier (sp1-sdk LightProver, the embedded keys) on a compressed shard proof of
|
||||
block-58927-empty-reward (1,272,897 B), one core, including the LightProver setup each call:
|
||||
|
||||
| Machine | Profile | Verify | Command |
|
||||
|---|---|---|---|
|
||||
| M5 Max (this Mac) | release | 0.204 s, 0.232 s (two runs) | `cargo test --release -p igneum-exec nativeverify -- --nocapture` with the fixture |
|
||||
| M5 Max | debug | 1.226 s | the same without `--release` |
|
||||
| igneum-build-1 (Linux, the build box) | release | 0.400 s | `tools/build-remote.sh --no-fetch -- test --release -p igneum-exec nativeverify -- --nocapture` |
|
||||
| EPYC 7K62, 2019 Zen 2 (rz-4090, earlier today) | release, through the host | 0.53 s | bench/proof-systems rows |
|
||||
|
||||
`node tools/exec-sync/forged.mjs` (fast-time simnet, `proving_consensus_verify_daa` 0 under the embedded ids): 8/8 in
|
||||
23.6 s. The attacker A (`IGNEUM_TEST_SKIP_PROOF_RULE=1`, Trust pool) carried a forged shard record at block 27; B asked
|
||||
A for the proof (22:03:31.270 local), held it 1 ms later, refused the block at 31.522 ("the proof bytes are not a bincode
|
||||
SP1 proof"; the verify itself 0.000 s), never included it and paid nothing; before the forgery B followed A block for
|
||||
block. An unmodified A refused its own carrying block ("block is known to be invalid", 20:45Z run).
|
||||
|
||||
Consequence per tier: the rule costs an honest node nothing on the hot path (a proof verified at relay time is a
|
||||
cache hit); a cold proof costs a 2019 core 0.4 to 0.5 s and a current one 0.2 s, in parallel per record; a forger
|
||||
loses its block and its peer. Until 0.3.17.1's finality-horizon skip, a fresh joiner fetches every carried proof in
|
||||
its IBD window from its syncer (1.27 MB a record), so a syncer must still hold them.
|
||||
|
||||
### 6 October 2026, 22:10Z to 22:28Z, Devnet 2: the zero program-id class and its close (the fleet's reads)
|
||||
|
||||
Devnet 2 (igneum-devnet-2, five nodes on 4c6b129d, 1 block/s, every switch at DAA 0): the nodes ran without a verifier
|
||||
host or IGNEUM_PROOF_PROGRAM_IDS, so their expected segment statements carried zero program ids at hex offset 472 and
|
||||
every segment record from the shipped host was refused while 8 of 8 shards passed (seg 2868: chain proof 1.27 MB in
|
||||
281 s on a 4090). Fix on 4c6b129d: IGNEUM_PROOF_PROGRAM_IDS=0x2b1a81cb...,0x474678f3... on each node process, restart
|
||||
22:10Z to 22:16Z, same genesis. Result: paidSegments 2 on every node at 22:22:28Z and 4 at 22:28:10Z (two provers,
|
||||
8-block segments), 0 PoW rejected, one exec state root on all five at height 3,792 (0xab19a0be...), one version
|
||||
(2.1.0-1279a1d6, digest 4a0b8726). On the 0.3.17 node (fork a344fea3) the override object's proving_shard_program_id
|
||||
and proving_aggregator_id are the statement's ids when set, so the env is not needed. Consequence per tier: a node
|
||||
without a host (a pool hand, a seed, a home node that does not prove) must carry the ids in its object or its env, or
|
||||
it refuses every segment record and pays no aggregator; the 0.3.17 object carries them.
|
||||
## 6 October 2026, 22:16Z: the shipped 0.3.16 app asked to tune itself on PC 1 (ember-installed-pc1-2): A to E held, no climb (cause F)
|
||||
|
||||
Gate ok (0.3.16, Power control on, task on the installed exe, Running at the end), prompts 0, the helper up once per
|
||||
NVIDIA card. RX 9070 XT measured as it runs: 19.2 MH/s at 202 W (0.095 MH/W), no set sent (measure only: the probe
|
||||
gave no tune line; open). RTX 5090 and 4070 climbs stopped at request 1: "the helper did not run sequence 1 within
|
||||
15 s"; cmd.txt held "00 dev 1 / 01 pl 160 / 02 rgc / 03 rmc", two-digit wire numbers below the cap path's unix-based
|
||||
ones, so the helper skipped them as stale (cause F, fixed cbd4d51 for 0.3.17). Draws unchanged: 5090 208 W at
|
||||
1845 MHz, 4070 75.5 W at 1860 MHz, 9070 XT 203 W.
|
||||
|
||||
## Block rate on Devnet 2, 6 October 2026 (branch gpu-fleet): 10 blocks per second against 1 on 42 rented cards
|
||||
|
||||
|
|
|
|||
78
docs/community/discord/ladder.md
Normal file
78
docs/community/discord/ladder.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Discord: the ladder's rung announcements (message shapes, not posted)
|
||||
|
||||
7 October 2026, miner-ui-5 (mission item 6, docs/analysis/mission/mission.md 2.6). The shapes the bot would post for
|
||||
each rung of the miner's ladder, so the words in Discord match the app and the site rung for rung. Nothing here is
|
||||
posted by this lane; the Discord lane wires them into `tools/community/discord-hooks.mjs` behind its guard rails
|
||||
(docs/community/discord-hooks.md: the forbidden-string guard, the limits, idempotency keys, never a mention). Every
|
||||
number is a chain fact from the public API (`/api/live`, `/api/stats`), which is the observer's copy of the node's
|
||||
`getFinalityWeights` and `getFinalityCheckpoints`; a post names nothing the chain does not say.
|
||||
|
||||
## The rules every rung post obeys
|
||||
|
||||
| Rule | How |
|
||||
|---|---|
|
||||
| The miner's object, never the project's | a post carries the key id, the block link, the rank and the day; never the network hashrate, the block count or a milestone of the project |
|
||||
| A chain fact only | the trigger is a field of `getFinalityWeights` (`keys[].blocks`, `keys[].voter`, `keys[].participation`, `voters`) or `getFinalityCheckpoints` (`latestLockedIndex`), read through `/api/live finality.weights` |
|
||||
| Opt-in for anything beyond the key id | the card model and the "(you)" link to the address page appear only when the miner switched "Make my page public" on in the app (settings.profile_public); without it the post is the key id and the block link |
|
||||
| Short key id | the first 8 hex of the vote key hash, the same id the app, `/live` and the explorer show; never a payout address, never a machine name |
|
||||
| One post per key per rung | idempotency key `rung:<rung>:<keyid>`; a rung is posted once for a key, ever (a key that drops and climbs back is not posted again) |
|
||||
| Copy law | no em dashes, no two-beat antithesis, no aphorisms; the numbers in the line, nothing decorative |
|
||||
| Volume | #first-blocks takes rungs 0 and 1 (one line each); #numbers takes the daily ladder summary; rungs 2 to 5 and P are never posted singly (one a minute at scale), they are counted in the summary |
|
||||
|
||||
## Channel: #first-blocks
|
||||
|
||||
### Rung 0, first block (trigger: a key's first blue block in the window; `keys[].blocks` goes 0 to 1 in `finality.weights`, and the block's `miner` is that key in `/api/live blocks[]`)
|
||||
|
||||
```
|
||||
First block: key 6ad0e117
|
||||
Block 1,284,117 · igneum.network/block/9f3a…c21e
|
||||
RTX 4070 (the miner chose to show the card)
|
||||
```
|
||||
|
||||
Without the opt-in the third line is absent. Embed: ember accent, title "First block", one field "Key" = `6ad0e117`, one field "Block" = the explorer link; footer the UK time with UTC in brackets (the hooks script's footer rule).
|
||||
|
||||
### Rung 1, the vote (trigger: `keys[].voter` goes false to true, that is `blocks` reaches `params.dust`)
|
||||
|
||||
```
|
||||
Your key has a vote: 6ad0e117
|
||||
100 blocks in the window (the line is 100) · the key now signs every checkpoint
|
||||
```
|
||||
|
||||
On the devnet the line reads `5 blocks in the window (the line is 5)`: the number is `params.dust`, never a constant.
|
||||
|
||||
## Channel: #numbers, the daily ladder summary (09:00 UK, beside the daily digest)
|
||||
|
||||
```
|
||||
The ladder, yesterday
|
||||
First blocks: 14 keys found their first block
|
||||
Votes: 9 keys crossed the dust line (now 1,213 voters)
|
||||
Signatures: 1,201 keys signed a locked checkpoint (latest lock 184,220)
|
||||
Full window: 406 keys at 30 of 30 days
|
||||
Rank 1 by weight: 6e80f3ef with 2,592 blocks in the window
|
||||
Signing streak, longest: 41 days unbroken (presence 100%)
|
||||
Shards: 388 paid to 57 keys
|
||||
```
|
||||
|
||||
Fields and sources, one per line:
|
||||
|
||||
| Line | Field |
|
||||
|---|---|
|
||||
| First blocks | keys whose `blocks` went from 0 to at least 1 between the two daily reads of `finality.weights.keys[]` |
|
||||
| Votes | keys whose `voter` went false to true; `voters` for the total |
|
||||
| Signatures | keys with `participation` above 0 at the day's last read; `latest_locked_index` for the lock |
|
||||
| Full window | keys whose `blocks` span the whole `params.weightWindow` (owed from the node: `keys[].daysMined`; until then the line is left out, never estimated) |
|
||||
| Rank 1 | `keys[]` sorted by `blocks`, the top row |
|
||||
| Signing streak | owed from the node (`keys[].streakDays`); until then the line is left out |
|
||||
| Shards | `/api/live proving.shards_paid_10m` summed over the day's reads, provers from `live_proofs.prover` |
|
||||
|
||||
A line whose field the node does not expose yet is left out of the post rather than estimated (the "no number a company server has to be trusted for" rule, reinvent.md section 6).
|
||||
|
||||
## Roles (the Discord lane, from the same fields)
|
||||
|
||||
| Role | Granted when | Revoked when |
|
||||
|---|---|---|
|
||||
| Voter | a message signed with the vote key names a key whose `voter` is true in `getFinalityWeights` | `voter` false at a daily read (a key under dust, or stripped: `strippedUntilDaa` above the DAA score) |
|
||||
| Window | the key's blocks span the full `params.weightWindow` (`keys[].daysMined` owed; until then by hand from the ladder summary) | the key misses a day |
|
||||
| Prover | a paid shard record names the key (`igneum_getProofRecords(block).paid[shard]`) | never (a paid record is a chain fact) |
|
||||
|
||||
The signed message is the proof of key ownership; the app does not sign it yet (owed: a "prove my key" button that signs a nonce with the vote key and copies it).
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 362.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.3 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.445 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.106 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.004 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.7 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.905 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.731 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.723 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.714 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 368.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.5 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.089 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.935 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.934 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.1 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.059 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.067 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 366.6 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.512 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.350 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.342 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.342 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.0 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.044 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.887 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.887 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.900 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 486.8 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.386 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.244 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.254 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.231 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 541.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 525.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.834 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.701 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.717 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.712 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 540.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 426.2 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.078 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.936 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.928 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.931 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.5 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 429.9 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.201 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.070 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.062 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.055 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 436.9 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.497 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.360 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.359 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.350 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 539.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 450.4 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.066 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.881 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.884 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.829 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 19.77 12.55 8.74
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.4 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 9.918 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 9.703 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 9.691 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 10.870 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.8 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 11.728 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 11.491 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 11.493 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 13.125 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 365.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 545.3 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.146 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 6.915 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 6.897 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 7.421 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.5 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.291 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 7.057 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 7.070 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 7.587 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 547.1 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.627 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 7.404 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 7.380 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 8.023 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 544.8 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.290 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 8.085 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 8.051 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 8.846 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
10
docs/design/latency-ladder-bench/20261006T220642Z/table.md
Normal file
10
docs/design/latency-ladder-bench/20261006T220642Z/table.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
host igneum-build-1 load 26.61 13.24 8.88 freq40 1519940 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 19.77 12.55 8.74
|
||||
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 27 | 55296 | 102,122 | 5.09 | 4.93 | 5.08 | 4.93 | yes |
|
||||
| 1 | 35 | 71680 | 132,105 | 5.20 | 5.07 | 5.20 | 5.05 | yes |
|
||||
| 2 | 53 | 108544 | 199,566 | 5.51 | 5.34 | 5.50 | 5.35 | yes |
|
||||
| 3 | 88 | 180224 | 330,740 | 6.04 | 5.90 | 6.07 | 5.83 | yes |
|
||||
| 4 | 173 | 354304 | 649,307 | 7.45 | 7.00 | 7.39 | 7.23 | yes |
|
||||
| 5 | 267 | 546816 | 1,001,604 | 8.90 | 8.71 | 8.83 | 8.71 | yes |
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 365.6 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.380 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.232 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.230 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 7.233 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 364.0 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 367.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.881 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.723 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.707 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 8.499 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 460.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.6 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.141 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.954 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.969 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 4.946 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 456.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 437.3 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.571 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.327 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.203 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 5.521 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 368.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.8 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.569 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.352 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.366 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 5.351 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 360.9 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 361.8 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.073 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.900 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.883 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 5.892 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 548.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 551.8 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 12.383 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 11.970 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 12.177 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
CPU verify: 12.091 ms per 32-lane warp, avg of 50 (checksum e9371a9db9fc1817)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 548.8 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.3 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 14.959 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 14.619 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 14.437 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
CPU verify: 14.580 ms per 32-lane warp, avg of 50 (checksum a30f9fb7574d0db3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 551.2 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 561.9 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.769 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 8.503 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 8.469 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
CPU verify: 8.674 ms per 32-lane warp, avg of 50 (checksum 17e36e7905b81375)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 549.4 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 554.8 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.867 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 8.505 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 8.517 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
CPU verify: 8.510 ms per 32-lane warp, avg of 50 (checksum 4632b2b31b01b311)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 541.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 549.4 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 9.233 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 8.869 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 8.943 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
CPU verify: 8.908 ms per 32-lane warp, avg of 50 (checksum 2efdcc71de21f16f)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 542.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 556.0 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 10.081 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 9.845 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 9.944 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
CPU verify: 9.842 ms per 32-lane warp, avg of 50 (checksum 77189c69c05913b5)
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
host igneum-build-1 load 25.39 17.85 11.63 freq40 1500000 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 23.60 18.46 12.05
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 363.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x173, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.9 ms
|
||||
shadow: 256 instructions x 173 passes per iteration, 354304 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 7.394 ms, 4096 items derived, lane0 74dbb9c7808b6c12 lane31 14a606e8cc2988ee
|
||||
warp base 4096: single cold run 7.241 ms, 4096 items derived, lane0 361270e067803cb3 lane31 979c376d679d74b2
|
||||
warp base 1000000: single cold run 7.269 ms, 4096 items derived, lane0 173be6655be48c63 lane31 697bcf4f945c5bf9
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 365.1 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x267, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 372.0 ms
|
||||
shadow: 256 instructions x 267 passes per iteration, 546816 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 8.930 ms, 4096 items derived, lane0 14e8e692f6f1f094 lane31 a25fb8045d382322
|
||||
warp base 4096: single cold run 8.716 ms, 4096 items derived, lane0 eed0185d04e6d213 lane31 6b9af2c86bf9db03
|
||||
warp base 1000000: single cold run 8.762 ms, 4096 items derived, lane0 a38b5590afcba174 lane31 39664cb2e742eb67
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 369.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x27, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 370.0 ms
|
||||
shadow: 256 instructions x 27 passes per iteration, 55296 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.129 ms, 4096 items derived, lane0 2576769ee4a14c8d lane31 c58ddcb717dd3370
|
||||
warp base 4096: single cold run 4.944 ms, 4096 items derived, lane0 1ce77a600ec573b4 lane31 03600a05ffba0055
|
||||
warp base 1000000: single cold run 4.947 ms, 4096 items derived, lane0 6b390e64bbdd91ce lane31 91c944d603539c62
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 366.7 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x35, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 368.5 ms
|
||||
shadow: 256 instructions x 35 passes per iteration, 71680 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.247 ms, 4096 items derived, lane0 177dd71be37996bc lane31 792114b1b40ad79f
|
||||
warp base 4096: single cold run 5.071 ms, 4096 items derived, lane0 fe6701666e2e483a lane31 fc2105cb89fcb8b7
|
||||
warp base 1000000: single cold run 5.063 ms, 4096 items derived, lane0 6401cfb6e9ddfed6 lane31 89445da95a2e7184
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 362.3 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x53, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.4 ms
|
||||
shadow: 256 instructions x 53 passes per iteration, 108544 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 5.481 ms, 4096 items derived, lane0 9771dab715d71261 lane31 525676cf580b6f11
|
||||
warp base 4096: single cold run 5.346 ms, 4096 items derived, lane0 1afa4ca3c965c3f5 lane31 4e238e93b5f0101d
|
||||
warp base 1000000: single cold run 5.349 ms, 4096 items derived, lane0 21a07085d7c0bd82 lane31 39fd0197590efb2a
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
igneum-pow bench: seed "igneum-genesis", day "2026-10-03", dataset 2^28 words (memory-hard)
|
||||
cache: fill 367.6 ms on one core (2^26 words, 256 MiB, 65536 chains of 64 ChaCha12 blocks), FNV-1a 64 48c4f5bf24166b2e
|
||||
program: class mx8+sh256x88, 128 loads/hash, 512 bytes/hash, widths (1,4,16 words) [16, 0, 0], 4096 items/warp, mixer x8 (72 mixers/item), cache 2^26 words, op mix load=16 add=8 shfl=8 xor=6 mad=5 mul=5 mulhi=5 sub=4 rotl=3 rotr=3 or=1; epoch built in 364.7 ms
|
||||
shadow: 256 instructions x 88 passes per iteration, 180224 shadow instructions per hash, op mix add=47 rotl=30 xor=30 shfl=29 mad=27 mul=22 sub=21 rotr=20 mulhi=18 or=12
|
||||
warp base 0: single cold run 6.058 ms, 4096 items derived, lane0 84beec0429c7ef83 lane31 334b204a4a68c30d
|
||||
warp base 4096: single cold run 5.909 ms, 4096 items derived, lane0 32be0f5b7d992255 lane31 2c8dbd443403bc84
|
||||
warp base 1000000: single cold run 5.896 ms, 4096 items derived, lane0 78541813d6547a59 lane31 fd7fb2e05bbc316f
|
||||
10
docs/design/latency-ladder-bench/20261006T221034Z/table.md
Normal file
10
docs/design/latency-ladder-bench/20261006T221034Z/table.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
host igneum-build-1 load 25.39 17.85 11.63 freq40 1500000 kHz siblings 40,88 pow 3f7623f208335f5b
|
||||
load after 23.60 18.46 12.05
|
||||
| Rung | reps | Shadow instrs per hash | Counted ops (approx) | Cold, core alone (ms) | Avg of 50, alone (ms) | Cold, sibling loaded (ms) | Avg of 50, sibling loaded (ms) | Under 10 ms loaded |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 27 | 55296 | 102,122 | 5.14 | 4.95 | 8.77 | 8.67 | yes |
|
||||
| 1 | 35 | 71680 | 132,105 | 5.57 | 5.52 | 8.87 | 8.51 | yes |
|
||||
| 2 | 53 | 108544 | 199,566 | 5.57 | 5.35 | 9.23 | 8.91 | yes |
|
||||
| 3 | 88 | 180224 | 330,740 | 6.07 | 5.89 | 10.08 | 9.84 | NO |
|
||||
| 4 | 173 | 354304 | 649,307 | 7.38 | 7.23 | 12.38 | 12.09 | NO |
|
||||
| 5 | 267 | 546816 | 1,001,604 | 8.88 | 8.50 | 14.96 | 14.58 | NO |
|
||||
1028
docs/design/latency-ladder-harness/failed-case.json
Normal file
1028
docs/design/latency-ladder-harness/failed-case.json
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue