Add Path Tracer onion timeline fork/merge with ghost branches and Seer feed.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Operators fork at hop N to run persona ensembles in parallel on the target agent until mining links; winners merge back into the canonical SQLite-persisted session with WS and Seer events plus mermaid branch graphs in the UI.
This commit is contained in:
282
server/internal/api/pathtracer_timeline_test.go
Normal file
282
server/internal/api/pathtracer_timeline_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func readyTraceSessionWithHop(agentID string) *TraceSession {
|
||||
sess := &TraceSession{
|
||||
ID: "sess-fork-test-12345678",
|
||||
AgentIDs: []string{agentID},
|
||||
Ready: true,
|
||||
CreatedAt: time.Now(),
|
||||
Hops: []*HopInfo{{
|
||||
AgentID: agentID,
|
||||
AgentName: "Target Hop",
|
||||
ExternalIP: "203.0.113.5",
|
||||
Port: 51820,
|
||||
Status: HopReady,
|
||||
}},
|
||||
}
|
||||
initCanonicalTimeline(sess)
|
||||
return sess
|
||||
}
|
||||
|
||||
func TestBuildPathTracerMermaidIncludesGhostBranches(t *testing.T) {
|
||||
sess := readyTraceSessionWithHop("agent-target")
|
||||
sess.TimelineBranches = append(sess.TimelineBranches, &TimelineBranch{
|
||||
ID: "ghost-abc-12345678",
|
||||
ForkHopIndex: 0,
|
||||
Persona: "aggressive",
|
||||
Status: BranchRunning,
|
||||
IsGhost: true,
|
||||
})
|
||||
out := buildPathTracerMermaid(sess, timelineBranchSnapshot(sess))
|
||||
if !strings.Contains(out, "graph TD") {
|
||||
t.Fatal("expected mermaid graph")
|
||||
}
|
||||
if !strings.Contains(out, "aggressive") {
|
||||
t.Fatalf("expected persona in mermaid: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "class ghost_") {
|
||||
t.Fatalf("expected ghost node: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerForkSpawnsGhostBranches(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
agentID := "fork-agent-001"
|
||||
_ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Hop One", Status: "online"})
|
||||
|
||||
hub := NewWSHub(database)
|
||||
startPathTracerAgentResponder(t, hub, agentID, "FORK_PUB_KEY")
|
||||
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := readyTraceSessionWithHop(agentID)
|
||||
h.mu.Lock()
|
||||
h.sessions[sess.ID] = sess
|
||||
h.mu.Unlock()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"fork_hop_index": 0,
|
||||
"personas": []string{"aggressive", "silent"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/fork", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Fork(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("fork status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Branches []TimelineBranch `json:"branches"`
|
||||
Mermaid string `json:"mermaid"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Branches) != 2 {
|
||||
t.Fatalf("want 2 branches, got %d", len(resp.Branches))
|
||||
}
|
||||
for _, b := range resp.Branches {
|
||||
if !b.IsGhost || b.Status != BranchRunning {
|
||||
t.Fatalf("unexpected branch: %+v", b)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(resp.Mermaid, "aggressive") {
|
||||
t.Fatalf("mermaid missing persona: %s", resp.Mermaid)
|
||||
}
|
||||
|
||||
restored := h.getSession(sess.ID)
|
||||
if len(restored.TimelineBranches) < 3 {
|
||||
t.Fatalf("expected canonical + 2 ghosts, got %d", len(restored.TimelineBranches))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerMergeWinner(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
agentID := "merge-agent-001"
|
||||
hub := NewWSHub(database)
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := readyTraceSessionWithHop(agentID)
|
||||
winner := &TimelineBranch{
|
||||
ID: "ghost-winner-branch",
|
||||
ParentID: sess.ID,
|
||||
ForkHopIndex: 0,
|
||||
TargetAgentID: agentID,
|
||||
Persona: "persuasive",
|
||||
SpreadLanes: []string{"dns_txt", "docker"},
|
||||
Status: BranchWon,
|
||||
MiningLinked: true,
|
||||
Hashrate: 420.5,
|
||||
IsGhost: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
sess.TimelineBranches = append(sess.TimelineBranches, winner)
|
||||
h.mu.Lock()
|
||||
h.sessions[sess.ID] = sess
|
||||
h.mu.Unlock()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"branch_id": winner.ID,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/merge", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Merge(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("merge status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
MergedPersona string `json:"merged_persona"`
|
||||
MergedSpreadLane string `json:"merged_spread_lane"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.MergedPersona != "persuasive" || resp.MergedSpreadLane != "dns_txt" {
|
||||
t.Fatalf("merge response: %+v", resp)
|
||||
}
|
||||
|
||||
restored := h.getSession(sess.ID)
|
||||
if restored.MergedPersona != "persuasive" || restored.MergedBranchID != winner.ID {
|
||||
t.Fatalf("session merge fields not set: %+v", restored)
|
||||
}
|
||||
if findTimelineBranch(restored, winner.ID).Status != BranchMerged {
|
||||
t.Fatal("winner should be merged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerForkRejectsUnreadySession(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := readyTraceSessionWithHop("agent-x")
|
||||
sess.Ready = false
|
||||
h.mu.Lock()
|
||||
h.sessions[sess.ID] = sess
|
||||
h.mu.Unlock()
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"session_id": sess.ID,
|
||||
"fork_hop_index": 0,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/pathtrace/fork", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Fork(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerTimelinePersistRoundTrip(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
h1 := NewPathTracerHandler(hub)
|
||||
sess := readyTraceSessionWithHop("persist-agent")
|
||||
sess.TimelineBranches = append(sess.TimelineBranches, &TimelineBranch{
|
||||
ID: "ghost-persist",
|
||||
Persona: "balanced",
|
||||
Status: BranchRunning,
|
||||
IsGhost: true,
|
||||
})
|
||||
sess.MergedPersona = "balanced"
|
||||
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 || restored.MergedPersona != "balanced" {
|
||||
t.Fatalf("timeline not restored: %+v", restored)
|
||||
}
|
||||
if len(restored.TimelineBranches) < 2 {
|
||||
t.Fatal("branches not restored from SQLite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTracerStatusIncludesTimeline(t *testing.T) {
|
||||
hub := NewWSHub(nil)
|
||||
h := NewPathTracerHandler(hub)
|
||||
sess := readyTraceSessionWithHop("status-agent")
|
||||
h.mu.Lock()
|
||||
h.sessions[sess.ID] = sess
|
||||
h.mu.Unlock()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/pathtrace/{id}/status", h.Status)
|
||||
req := httptest.NewRequest(http.MethodGet, "/pathtrace/"+sess.ID+"/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "timeline_branches") {
|
||||
t.Fatalf("missing timeline in status: %s", rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "mermaid") {
|
||||
t.Fatalf("missing mermaid in status: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentMiningHashratePrefersLiveTelemetry(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
agentID := "hr-agent"
|
||||
_ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Miner", Status: "online"})
|
||||
hub := NewWSHub(database)
|
||||
hub.cacheAgentTelemetry(agentID, map[string]interface{}{
|
||||
"mining_hashrate": 777.0,
|
||||
"hashrate_15s": 100.0,
|
||||
})
|
||||
h := NewPathTracerHandler(hub)
|
||||
if got := h.agentMiningHashrate(agentID); got != 777 {
|
||||
t.Fatalf("want live mining_hashrate 777, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentMiningHashrateFallsBackToDB(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
agentID := "hr-agent-db"
|
||||
_ = database.UpsertAgent(&models.Agent{ID: agentID, Name: "Miner", Status: "online"})
|
||||
_ = database.UpdateAgentStats(agentID, 555, 550, 540, 0, 0, 0, 0, 0, 0)
|
||||
h := NewPathTracerHandler(NewWSHub(database))
|
||||
if got := h.agentMiningHashrate(agentID); got != 555 {
|
||||
t.Fatalf("want hashrate_15s 555, got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user