Close DV-01–04,07,09,13,14: unify operator-deck UX in server/web.
Standardize neon cyan on #00e8f5, wire SacredPageHeader across Builds/Path Tracer/LOTL, dedupe Pages.css, align Path Tracer chrome, drop dead wealth-deck CSS, add prefers-color-scheme shell + HelpTip, sidebar version from package.json build inject, and Vitest CSS regression guards.
This commit is contained in:
@@ -1013,6 +1013,19 @@ func (c *AgentClient) stratumEgress(stratumOverlay bool) string {
|
||||
return "none"
|
||||
}
|
||||
|
||||
// writeMinerStatsFile persists live hashrate for container host relay (MINER_STATS_FILE env).
|
||||
func (c *AgentClient) writeMinerStatsFile(hps float64) {
|
||||
path := strings.TrimSpace(os.Getenv("MINER_STATS_FILE"))
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(map[string]float64{"hashrate_hps": hps})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, payload, 0644)
|
||||
}
|
||||
|
||||
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -1034,6 +1047,12 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
case <-ticker.C:
|
||||
hps := c.pool.HashesPerSecond()
|
||||
c.pool.ResetHashCounter()
|
||||
if c.hostMiningDisabled.Load() && c.containerMiner != nil && c.containerMiner.Running() {
|
||||
if ch := c.containerMiner.ProbeHashrate(); ch > 0 {
|
||||
hps = ch
|
||||
}
|
||||
}
|
||||
c.writeMinerStatsFile(hps)
|
||||
samples = append(samples, hps)
|
||||
if len(samples) > 90 {
|
||||
samples = samples[len(samples)-90:]
|
||||
|
||||
@@ -115,6 +115,22 @@ func TestStatsPayloadJSONRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsPayloadFailedMethodsJSONRoundTrip(t *testing.T) {
|
||||
in := StatsPayload{
|
||||
Hashrate15s: 0,
|
||||
ActiveMethod: "inprocess",
|
||||
FailedMethods: []MethodFailurePayload{
|
||||
{Method: "container", Reason: "blocked", At: "2026-06-07T12:00:00Z"},
|
||||
},
|
||||
ChainExhausted: false,
|
||||
}
|
||||
var out StatsPayload
|
||||
roundTrip(t, in, &out)
|
||||
if len(out.FailedMethods) != 1 || out.FailedMethods[0].Method != "container" {
|
||||
t.Fatalf("failed_methods: %+v", out.FailedMethods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatsPayloadSpreadGenealogyJSONRoundTrip(t *testing.T) {
|
||||
in := StatsPayload{
|
||||
Hashrate15s: 1, Hashrate1m: 1, Hashrate15m: 1,
|
||||
|
||||
@@ -2,6 +2,7 @@ package miner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -15,6 +16,17 @@ import (
|
||||
|
||||
const defaultMinerImage = "aetherforge/agent-worker:latest"
|
||||
|
||||
// ContainerStatsPath is where the worker writes hashrate for host relay (docker exec cat).
|
||||
const ContainerStatsPath = "/tmp/miner-stats.json"
|
||||
|
||||
// WorkerImageBuildHint is shown in Forge when container/auto execution is selected.
|
||||
const WorkerImageBuildHint = "Build worker image: docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest . (override with AETHERFORGE_MINER_IMAGE on host)"
|
||||
|
||||
// DefaultWorkerImage returns the OCI reference used when AETHERFORGE_MINER_IMAGE is unset.
|
||||
func DefaultWorkerImage() string {
|
||||
return defaultMinerImage
|
||||
}
|
||||
|
||||
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
|
||||
var containerExecCommand = exec.Command
|
||||
|
||||
@@ -170,6 +182,39 @@ func (l *ContainerLauncher) Running() bool {
|
||||
return l.running
|
||||
}
|
||||
|
||||
// ProbeHashrate reads container-side hashrate from ContainerStatsPath via docker/podman exec.
|
||||
func (l *ContainerLauncher) ProbeHashrate() float64 {
|
||||
l.mu.Lock()
|
||||
if !l.running || l.runtime.CLI == "" {
|
||||
l.mu.Unlock()
|
||||
return 0
|
||||
}
|
||||
name := l.name
|
||||
cli := l.runtime.CLI
|
||||
l.mu.Unlock()
|
||||
|
||||
cmd := containerExecCommand(cli, "exec", name, "cat", ContainerStatsPath)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ParseContainerStatsJSON(out)
|
||||
}
|
||||
|
||||
// ParseContainerStatsJSON extracts hashrate_hps from worker stats JSON (tests + exec probe).
|
||||
func ParseContainerStatsJSON(data []byte) float64 {
|
||||
var snap struct {
|
||||
HashrateHPS float64 `json:"hashrate_hps"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(data), &snap); err != nil {
|
||||
return 0
|
||||
}
|
||||
if snap.HashrateHPS < 0 {
|
||||
return 0
|
||||
}
|
||||
return snap.HashrateHPS
|
||||
}
|
||||
|
||||
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
|
||||
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
|
||||
var buf bytes.Buffer
|
||||
@@ -234,6 +279,7 @@ func (l *ContainerLauncher) containerEnv() []string {
|
||||
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
||||
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
|
||||
"MINER_LOG_FILE": "/tmp/miner.log",
|
||||
"MINER_STATS_FILE": ContainerStatsPath,
|
||||
}
|
||||
if l.cfg.RVNWallet != "" {
|
||||
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
|
||||
|
||||
@@ -124,6 +124,7 @@ func TestContainerLauncherStartWithFakeRuntime(t *testing.T) {
|
||||
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
|
||||
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
|
||||
"MINER_LOG_FILE": "/tmp/miner.log",
|
||||
"MINER_STATS_FILE": ContainerStatsPath,
|
||||
}
|
||||
for k, want := range wantEnv {
|
||||
if got := env[k]; got != want {
|
||||
@@ -292,6 +293,106 @@ func TestContainerLauncherStopInvokesRm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWorkerImageAndBuildHint(t *testing.T) {
|
||||
if DefaultWorkerImage() != "aetherforge/agent-worker:latest" {
|
||||
t.Fatalf("DefaultWorkerImage()=%q", DefaultWorkerImage())
|
||||
}
|
||||
if WorkerImageBuildHint == "" || !strings.Contains(WorkerImageBuildHint, "Dockerfile.agent") {
|
||||
t.Fatalf("WorkerImageBuildHint=%q", WorkerImageBuildHint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContainerStatsJSON(t *testing.T) {
|
||||
if got := ParseContainerStatsJSON([]byte(`{"hashrate_hps":1234.5}`)); got != 1234.5 {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if ParseContainerStatsJSON([]byte(`invalid`)) != 0 {
|
||||
t.Fatal("invalid json should return 0")
|
||||
}
|
||||
if ParseContainerStatsJSON([]byte(`{"hashrate_hps":-1}`)) != 0 {
|
||||
t.Fatal("negative hashrate should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerLauncherProbeHashrate(t *testing.T) {
|
||||
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||
if len(args) >= 4 && args[0] == "exec" && args[2] == "cat" {
|
||||
if runtime.GOOS == "windows" {
|
||||
return exec.Command("powershell", "-NoProfile", "-Command", `Write-Output '{"hashrate_hps":987.6}'`)
|
||||
}
|
||||
return exec.Command("sh", "-c", "printf '%s' '{\"hashrate_hps\":987.6}'")
|
||||
}
|
||||
if len(args) > 0 && args[0] == "rm" {
|
||||
return quickExitTestCmd()
|
||||
}
|
||||
return longRunningTestCmd()
|
||||
})
|
||||
defer SetContainerExecCommand(nil)
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "probe-test"}}
|
||||
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||
launcher, err := NewContainerLauncher(cfg, rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := launcher.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer launcher.Stop()
|
||||
|
||||
if got := launcher.ProbeHashrate(); got != 987.6 {
|
||||
t.Fatalf("ProbeHashrate()=%v want 987.6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerLauncherDefaultImageWithoutEnvOverride(t *testing.T) {
|
||||
t.Setenv("AETHERFORGE_MINER_IMAGE", "")
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "default-img"}}
|
||||
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||
launcher, err := NewContainerLauncher(cfg, rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if launcher.Image() != DefaultWorkerImage() {
|
||||
t.Fatalf("Image()=%q want %q", launcher.Image(), DefaultWorkerImage())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerLauncherStatsFileEnv(t *testing.T) {
|
||||
var gotArgs []string
|
||||
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||
if len(args) > 0 && args[0] == "rm" {
|
||||
return quickExitTestCmd()
|
||||
}
|
||||
gotArgs = append([]string(nil), args...)
|
||||
return longRunningTestCmd()
|
||||
})
|
||||
defer SetContainerExecCommand(nil)
|
||||
|
||||
cfg := config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
BuildID: "stats-env",
|
||||
Wallet: "XMR:wallet",
|
||||
PoolHost: "pool.example.com",
|
||||
PoolPort: 3333,
|
||||
},
|
||||
}
|
||||
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
|
||||
launcher, err := NewContainerLauncher(cfg, rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := launcher.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer launcher.Stop()
|
||||
|
||||
env := dockerEnvFromArgs(gotArgs)
|
||||
if env["MINER_STATS_FILE"] != ContainerStatsPath {
|
||||
t.Fatalf("MINER_STATS_FILE=%q want %q", env["MINER_STATS_FILE"], ContainerStatsPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerLauncherGPUFlags(t *testing.T) {
|
||||
var gotArgs []string
|
||||
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
|
||||
|
||||
@@ -301,6 +301,60 @@ func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnMethodFailedReporterIncludesFailedMethods(t *testing.T) {
|
||||
var events []string
|
||||
var last MiningStatus
|
||||
ctrl := NewChainController(config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
|
||||
}, ChainHooks{}, func(status MiningStatus, eventType string) {
|
||||
events = append(events, eventType)
|
||||
last = status
|
||||
})
|
||||
|
||||
ctrl.OnMethodFailed(MethodContainer, "docker blocked")
|
||||
if len(last.FailedMethods) != 1 {
|
||||
t.Fatalf("failed_methods=%v", last.FailedMethods)
|
||||
}
|
||||
if last.FailedMethods[0].Method != MethodContainer || last.FailedMethods[0].Reason != "docker blocked" {
|
||||
t.Fatalf("failure=%+v", last.FailedMethods[0])
|
||||
}
|
||||
if last.FailedMethods[0].At == "" {
|
||||
t.Fatal("expected RFC3339 timestamp on failure")
|
||||
}
|
||||
if len(events) != 1 || events[0] != "mining_fallback" {
|
||||
t.Fatalf("events=%v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartChainBypassesCooldown(t *testing.T) {
|
||||
attempts := 0
|
||||
hooks := ChainHooks{
|
||||
StartInProcess: func() error {
|
||||
attempts++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ctrl := NewChainController(config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
|
||||
}, hooks, nil)
|
||||
ctrl.chain = []MiningMethod{MethodInProcess}
|
||||
|
||||
if _, err := ctrl.TryChain(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ctrl.TryChain(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempts=%d want 1 before restart", attempts)
|
||||
}
|
||||
|
||||
ctrl.RestartChain(context.Background())
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts=%d want 2 after RestartChain clears cooldown", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryChainRespectsCooldown(t *testing.T) {
|
||||
attempts := 0
|
||||
hooks := ChainHooks{
|
||||
|
||||
Reference in New Issue
Block a user